feat(catalog): scope availability policies by subject
This commit is contained in:
9
app/Domains/Catalog/Enums/AvailabilitySubject.php
Normal file
9
app/Domains/Catalog/Enums/AvailabilitySubject.php
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Enums;
|
||||||
|
|
||||||
|
enum AvailabilitySubject: string
|
||||||
|
{
|
||||||
|
case Product = 'product';
|
||||||
|
case Variant = 'variant';
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user