feat(catalog): scope availability policies by subject

This commit is contained in:
2026-08-24 15:15:47 -03:00
parent a73b628bb4
commit 2323994f30
7 changed files with 126 additions and 32 deletions

View File

@@ -0,0 +1,9 @@
<?php
namespace App\Domains\Catalog\Enums;
enum AvailabilitySubject: string
{
case Product = 'product';
case Variant = 'variant';
}

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Catalog\Resources; namespace App\Domains\Catalog\Resources;
use App\Domains\Catalog\Enums\AvailabilitySubject;
use App\Domains\Catalog\Enums\InventoryPolicy; use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Enums\ProductLayout; use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\CatalogItem;
@@ -47,6 +48,11 @@ class CatalogFeaturedItemResource extends JsonResource
$variantStock = $catalogItem->inventory_policy === InventoryPolicy::Unlimited $variantStock = $catalogItem->inventory_policy === InventoryPolicy::Unlimited
? null ? null
: $variant->inventory->availableStock(); : $variant->inventory->availableStock();
$availability = $this->availability(
$variantStock,
$remainingUserQuota,
AvailabilitySubject::Variant,
);
return [ return [
'id' => $variant->id, '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(), 'event_dates' => $variant->selectedEventDates()->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, '.', ''),
'availability' => $this->availability( 'availability' => $availability,
$variantStock,
$remainingUserQuota,
'variant',
),
'values' => $variant->selectorOptions($catalogItem->itemAttributes), 'values' => $variant->selectorOptions($catalogItem->itemAttributes),
]; ];
}) })
->filter(fn (array $variant): bool => $variant['availability']['capabilities']['display'])
->values(), ->values(),
]; ];
@@ -117,9 +120,9 @@ class CatalogFeaturedItemResource extends JsonResource
private function availability( private function availability(
?int $stock, ?int $stock,
?int $remainingUserQuota, ?int $remainingUserQuota,
string $scope = 'product', AvailabilitySubject $subject = AvailabilitySubject::Product,
): array { ): array {
return app(CatalogItemAllowanceService::class) return app(CatalogItemAllowanceService::class)
->availability($stock, $remainingUserQuota, $scope); ->availability($stock, $remainingUserQuota, $subject);
} }
} }

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Catalog\Resources; namespace App\Domains\Catalog\Resources;
use App\Domains\Catalog\Enums\AvailabilitySubject;
use App\Domains\Catalog\Enums\InventoryPolicy; use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\ItemAttribute; use App\Domains\Catalog\Models\ItemAttribute;
@@ -45,6 +46,7 @@ class CatalogItemDetailResource extends JsonResource
), ),
'variants' => $this->variants 'variants' => $this->variants
->map(fn (Variant $variant): array => $this->variantData($variant)) ->map(fn (Variant $variant): array => $this->variantData($variant))
->filter(fn (array $variant): bool => $variant['availability']['capabilities']['display'])
->values(), ->values(),
'selected_variant' => $this->when( 'selected_variant' => $this->when(
$selectedVariant !== null, $selectedVariant !== null,
@@ -157,6 +159,10 @@ class CatalogItemDetailResource extends JsonResource
$values = $variant->selectionOptions($this->itemAttributes); $values = $variant->selectionOptions($this->itemAttributes);
$eventDates = $variant->selectedEventDates(); $eventDates = $variant->selectedEventDates();
$variantStock = $this->variantStock($variant); $variantStock = $this->variantStock($variant);
$availability = $this->availability(
$variantStock,
AvailabilitySubject::Variant,
);
return [ return [
'id' => $variant->id, '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(), '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, '.', ''),
'availability' => $this->availability($variantStock, 'variant'), 'availability' => $availability,
'values' => $values, 'values' => $values,
]; ];
} }
@@ -187,12 +193,14 @@ class CatalogItemDetailResource extends JsonResource
} }
/** @return array<string, mixed> */ /** @return array<string, mixed> */
private function availability(?int $stock, string $scope = 'product'): array private function availability(
{ ?int $stock,
AvailabilitySubject $subject = AvailabilitySubject::Product,
): array {
return app(CatalogItemAllowanceService::class)->availability( return app(CatalogItemAllowanceService::class)->availability(
$stock, $stock,
$this->getAttribute('remaining_user_quota'), $this->getAttribute('remaining_user_quota'),
$scope, $subject,
); );
} }
} }

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Catalog\Resources; namespace App\Domains\Catalog\Resources;
use App\Domains\Catalog\Enums\AvailabilitySubject;
use App\Domains\Catalog\Enums\InventoryPolicy; use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Models\Variant;
@@ -34,6 +35,10 @@ class CatalogSearchItemResource extends JsonResource
$variantStock = $this->inventory_policy === InventoryPolicy::Unlimited $variantStock = $this->inventory_policy === InventoryPolicy::Unlimited
? null ? null
: $variant->inventory?->availableStock(); : $variant->inventory?->availableStock();
$availability = $this->availability(
$variantStock,
AvailabilitySubject::Variant,
);
return [ return [
'id' => $variant->id, '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(), 'event_dates' => $variant->selectedEventDates()->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, '.', ''),
'availability' => $this->availability($variantStock, 'variant'), 'availability' => $availability,
'values' => $variant->selectorOptions($this->itemAttributes), 'values' => $variant->selectorOptions($this->itemAttributes),
]; ];
}) })
->filter(fn (array $variant): bool => $variant['availability']['capabilities']['display'])
->values(), ->values(),
]; ];
} }
/** @return array<string, mixed> */ /** @return array<string, mixed> */
private function availability(?int $stock, string $scope = 'product'): array private function availability(
{ ?int $stock,
AvailabilitySubject $subject = AvailabilitySubject::Product,
): array {
return app(CatalogItemAllowanceService::class)->availability( return app(CatalogItemAllowanceService::class)->availability(
$stock, $stock,
$this->getAttribute('remaining_user_quota'), $this->getAttribute('remaining_user_quota'),
$scope, $subject,
); );
} }
} }

View File

@@ -2,8 +2,10 @@
namespace App\Domains\Catalog\Services; namespace App\Domains\Catalog\Services;
use App\Domains\Catalog\Enums\AvailabilitySubject;
use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Purchase\Services\UserPurchaseLimitService; use App\Domains\Purchase\Services\UserPurchaseLimitService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
class CatalogItemAllowanceService class CatalogItemAllowanceService
@@ -42,17 +44,42 @@ class CatalogItemAllowanceService
return min($availableStock, $remainingUserQuota); return min($availableStock, $remainingUserQuota);
} }
/** @param Builder<CatalogItem> $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{ * @return array{
* subject: string,
* maximum_quantity: int|null, * maximum_quantity: int|null,
* restrictions: list<array{code: string, message: string, scope: string}>, * restrictions: list<array{code: string, message: string}>,
* capabilities: array{select_variant: bool, change_quantity: bool, add_to_cart: bool, buy_now: bool} * capabilities: array{display: bool, select_variant: bool, change_quantity: bool, add_to_cart: bool, buy_now: bool}
* } * }
*/ */
public function availability( public function availability(
?int $availableStock, ?int $availableStock,
?int $remainingUserQuota, ?int $remainingUserQuota,
string $scope = 'product', AvailabilitySubject $subject = AvailabilitySubject::Product,
): array { ): array {
$restrictions = []; $restrictions = [];
@@ -60,7 +87,6 @@ class CatalogItemAllowanceService
$restrictions[] = [ $restrictions[] = [
'code' => 'user_quota_reached', 'code' => 'user_quota_reached',
'message' => self::USER_QUOTA_REACHED_MESSAGE, 'message' => self::USER_QUOTA_REACHED_MESSAGE,
'scope' => $scope,
]; ];
} }
@@ -68,11 +94,11 @@ class CatalogItemAllowanceService
$restrictions[] = [ $restrictions[] = [
'code' => 'out_of_stock', 'code' => 'out_of_stock',
'message' => self::OUT_OF_STOCK_MESSAGE, 'message' => self::OUT_OF_STOCK_MESSAGE,
'scope' => $scope,
]; ];
} }
$capabilities = [ $capabilities = [
'display' => true,
'select_variant' => true, 'select_variant' => true,
'change_quantity' => true, 'change_quantity' => true,
'add_to_cart' => true, 'add_to_cart' => true,
@@ -81,7 +107,10 @@ class CatalogItemAllowanceService
foreach ($restrictions as $restriction) { foreach ($restrictions as $restriction) {
/** @var array<string, bool> $policy */ /** @var array<string, bool> $policy */
$policy = config("catalog.availability_policies.{$restriction['code']}", []); $policy = config(
"catalog.availability_policies.{$restriction['code']}.{$subject->value}",
[],
);
foreach ($capabilities as $capability => $allowed) { foreach ($capabilities as $capability => $allowed) {
$capabilities[$capability] = $allowed && ($policy[$capability] ?? true); $capabilities[$capability] = $allowed && ($policy[$capability] ?? true);
@@ -89,6 +118,7 @@ class CatalogItemAllowanceService
} }
return [ return [
'subject' => $subject->value,
'maximum_quantity' => $this->maximumAddableQuantity( 'maximum_quantity' => $this->maximumAddableQuantity(
$availableStock, $availableStock,
$remainingUserQuota, $remainingUserQuota,
@@ -107,7 +137,6 @@ class CatalogItemAllowanceService
$availability['restrictions'][] = [ $availability['restrictions'][] = [
'code' => 'requested_quantity_exceeds_user_quota', 'code' => 'requested_quantity_exceeds_user_quota',
'message' => $message, 'message' => $message,
'scope' => 'product',
]; ];
} }

View File

@@ -4,16 +4,36 @@ return [
'stock_reservation_expiration_minutes' => (int) env('STOCK_RESERVATION_EXPIRATION_MINUTES', 30), 'stock_reservation_expiration_minutes' => (int) env('STOCK_RESERVATION_EXPIRATION_MINUTES', 30),
'availability_policies' => [ 'availability_policies' => [
'user_quota_reached' => [ 'user_quota_reached' => [
'select_variant' => false, 'product' => [
'change_quantity' => false, 'display' => true,
'add_to_cart' => false, 'select_variant' => false,
'buy_now' => 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' => [ 'out_of_stock' => [
'select_variant' => false, 'product' => [
'change_quantity' => false, 'display' => false,
'add_to_cart' => false, 'select_variant' => false,
'buy_now' => 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,
],
], ],
], ],
]; ];

View File

@@ -2,6 +2,7 @@
namespace Tests\Unit\Catalog; namespace Tests\Unit\Catalog;
use App\Domains\Catalog\Enums\AvailabilitySubject;
use App\Domains\Catalog\Services\CatalogItemAllowanceService; use App\Domains\Catalog\Services\CatalogItemAllowanceService;
use App\Domains\Purchase\Services\UserPurchaseLimitService; use App\Domains\Purchase\Services\UserPurchaseLimitService;
use Mockery; use Mockery;
@@ -15,15 +16,16 @@ class CatalogItemAllowanceServiceTest extends TestCase
Mockery::mock(UserPurchaseLimitService::class), 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(0, $availability['maximum_quantity']);
$this->assertSame( $this->assertSame(
['user_quota_reached', 'out_of_stock'], ['user_quota_reached', 'out_of_stock'],
array_column($availability['restrictions'], 'code'), array_column($availability['restrictions'], 'code'),
); );
$this->assertSame(['variant', 'variant'], array_column($availability['restrictions'], 'scope'));
$this->assertSame([ $this->assertSame([
'display' => false,
'select_variant' => false, 'select_variant' => false,
'change_quantity' => false, 'change_quantity' => false,
'add_to_cart' => false, 'add_to_cart' => false,
@@ -39,11 +41,25 @@ class CatalogItemAllowanceServiceTest extends TestCase
$availability = $service->availability(5, 3); $availability = $service->availability(5, 3);
$this->assertSame('product', $availability['subject']);
$this->assertSame(3, $availability['maximum_quantity']); $this->assertSame(3, $availability['maximum_quantity']);
$this->assertSame([], $availability['restrictions']); $this->assertSame([], $availability['restrictions']);
$this->assertNotContains(false, $availability['capabilities'], true); $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 public function test_it_describes_a_requested_quantity_that_exceeds_the_remaining_quota(): void
{ {
$service = new CatalogItemAllowanceService( $service = new CatalogItemAllowanceService(
@@ -57,6 +73,7 @@ class CatalogItemAllowanceServiceTest extends TestCase
'requested_quantity_exceeds_user_quota', 'requested_quantity_exceeds_user_quota',
$availability['restrictions'][0]['code'], $availability['restrictions'][0]['code'],
); );
$this->assertTrue($availability['capabilities']['display']);
$this->assertTrue($availability['capabilities']['change_quantity']); $this->assertTrue($availability['capabilities']['change_quantity']);
} }
} }