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;
use App\Domains\Catalog\Enums\AvailabilitySubject;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Catalog\Models\CatalogItem;
@@ -47,6 +48,11 @@ class CatalogFeaturedItemResource extends JsonResource
$variantStock = $catalogItem->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);
}
}

View File

@@ -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<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(
$stock,
$this->getAttribute('remaining_user_quota'),
$scope,
$subject,
);
}
}

View File

@@ -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<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(
$stock,
$this->getAttribute('remaining_user_quota'),
$scope,
$subject,
);
}
}

View File

@@ -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<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{
* subject: string,
* maximum_quantity: int|null,
* restrictions: list<array{code: string, message: string, scope: string}>,
* capabilities: array{select_variant: bool, change_quantity: bool, add_to_cart: bool, buy_now: bool}
* restrictions: list<array{code: string, message: string}>,
* 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<string, bool> $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',
];
}

View File

@@ -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,
],
],
],
];

View File

@@ -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']);
}
}