diff --git a/app/Domains/Catalog/Controllers/CatalogController.php b/app/Domains/Catalog/Controllers/CatalogController.php index c25f22b..d35eb1c 100644 --- a/app/Domains/Catalog/Controllers/CatalogController.php +++ b/app/Domains/Catalog/Controllers/CatalogController.php @@ -3,7 +3,6 @@ namespace App\Domains\Catalog\Controllers; use App\Domains\Cart\Services\CartService; -use App\Domains\Catalog\Enums\AvailabilitySubject; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\FeaturedGroup; @@ -124,11 +123,10 @@ class CatalogController extends Controller $variantId === null ? null : (int) $variantId, ); $allowances->attach(collect([$item]), $this->userId($request)); - abort_unless($allowances->isVisible( + abort_unless($allowances->availability( $item->availableStock(), $item->getAttribute('remaining_user_quota'), - AvailabilitySubject::Product, - ), 404); + )->isVisible(), 404); return CatalogItemDetailResource::make($item); } diff --git a/app/Domains/Catalog/Enums/AvailabilityEffect.php b/app/Domains/Catalog/Enums/AvailabilityEffect.php new file mode 100644 index 0000000..87c01d0 --- /dev/null +++ b/app/Domains/Catalog/Enums/AvailabilityEffect.php @@ -0,0 +1,10 @@ + $catalogItem->nombre, 'descripcion' => $catalogItem->descripcion, 'precio' => $catalogItem->precio, - 'availability' => $this->availability( - $availableStock, - $remainingUserQuota, - ), + 'availability' => $this->availability($availableStock, $remainingUserQuota), 'variants' => $catalogItem->variants - ->map(function (Variant $variant) use ($catalogItem, $remainingUserQuota): array { + ->map(function (Variant $variant) use ($catalogItem): array { $variantStock = $catalogItem->inventory_policy === InventoryPolicy::Unlimited ? null : $variant->inventory->availableStock(); - $availability = $this->availability( - $variantStock, - $remainingUserQuota, - AvailabilitySubject::Variant, - ); + $availability = $this->availability($variantStock, null); return [ 'id' => $variant->id, @@ -66,7 +58,7 @@ class CatalogFeaturedItemResource extends JsonResource 'values' => $variant->selectorOptions($catalogItem->itemAttributes), ]; }) - ->filter(fn (array $variant): bool => $variant['availability']['capabilities']['display']) + ->filter(fn (array $variant): bool => $variant['availability']['state'] === 'visible') ->values(), ]; @@ -120,9 +112,9 @@ class CatalogFeaturedItemResource extends JsonResource private function availability( ?int $stock, ?int $remainingUserQuota, - AvailabilitySubject $subject = AvailabilitySubject::Product, ): array { return app(CatalogItemAllowanceService::class) - ->availability($stock, $remainingUserQuota, $subject); + ->availability($stock, $remainingUserQuota) + ->toArray(); } } diff --git a/app/Domains/Catalog/Resources/CatalogItemDetailResource.php b/app/Domains/Catalog/Resources/CatalogItemDetailResource.php index 1fdb1de..1883f61 100644 --- a/app/Domains/Catalog/Resources/CatalogItemDetailResource.php +++ b/app/Domains/Catalog/Resources/CatalogItemDetailResource.php @@ -2,7 +2,6 @@ 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; @@ -46,7 +45,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']) + ->filter(fn (array $variant): bool => $variant['availability']['state'] === 'visible') ->values(), 'selected_variant' => $this->when( $selectedVariant !== null, @@ -161,7 +160,7 @@ class CatalogItemDetailResource extends JsonResource $variantStock = $this->variantStock($variant); $availability = $this->availability( $variantStock, - AvailabilitySubject::Variant, + false, ); return [ @@ -195,12 +194,11 @@ class CatalogItemDetailResource extends JsonResource /** @return array */ private function availability( ?int $stock, - AvailabilitySubject $subject = AvailabilitySubject::Product, + bool $includeUserQuota = true, ): array { return app(CatalogItemAllowanceService::class)->availability( $stock, - $this->getAttribute('remaining_user_quota'), - $subject, - ); + $includeUserQuota ? $this->getAttribute('remaining_user_quota') : null, + )->toArray(); } } diff --git a/app/Domains/Catalog/Resources/CatalogSearchItemResource.php b/app/Domains/Catalog/Resources/CatalogSearchItemResource.php index 7b23bd4..ab36c39 100644 --- a/app/Domains/Catalog/Resources/CatalogSearchItemResource.php +++ b/app/Domains/Catalog/Resources/CatalogSearchItemResource.php @@ -2,7 +2,6 @@ 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; @@ -37,7 +36,7 @@ class CatalogSearchItemResource extends JsonResource : $variant->inventory?->availableStock(); $availability = $this->availability( $variantStock, - AvailabilitySubject::Variant, + false, ); return [ @@ -52,7 +51,7 @@ class CatalogSearchItemResource extends JsonResource 'values' => $variant->selectorOptions($this->itemAttributes), ]; }) - ->filter(fn (array $variant): bool => $variant['availability']['capabilities']['display']) + ->filter(fn (array $variant): bool => $variant['availability']['state'] === 'visible') ->values(), ]; } @@ -60,12 +59,11 @@ class CatalogSearchItemResource extends JsonResource /** @return array */ private function availability( ?int $stock, - AvailabilitySubject $subject = AvailabilitySubject::Product, + bool $includeUserQuota = true, ): array { return app(CatalogItemAllowanceService::class)->availability( $stock, - $this->getAttribute('remaining_user_quota'), - $subject, - ); + $includeUserQuota ? $this->getAttribute('remaining_user_quota') : null, + )->toArray(); } } diff --git a/app/Domains/Catalog/Services/AvailabilityDecision.php b/app/Domains/Catalog/Services/AvailabilityDecision.php new file mode 100644 index 0000000..f86b825 --- /dev/null +++ b/app/Domains/Catalog/Services/AvailabilityDecision.php @@ -0,0 +1,63 @@ + $allowedActions + * @param list $reasons + */ + private function __construct( + private bool $visible, + private ?int $maximumQuantity, + private array $allowedActions, + private array $reasons, + ) {} + + /** @param list $reasons */ + public static function hidden(array $reasons): self + { + return new self(false, null, [], $reasons); + } + + /** + * @param list $allowedActions + * @param list $reasons + */ + public static function visible( + ?int $maximumQuantity, + array $allowedActions, + array $reasons, + ): self { + return new self(true, $maximumQuantity, $allowedActions, $reasons); + } + + public function isVisible(): bool + { + return $this->visible; + } + + /** @return array */ + public function toArray(): array + { + if (! $this->visible) { + return [ + 'state' => 'hidden', + 'reasons' => $this->reasons, + ]; + } + + return [ + 'state' => 'visible', + 'maximum_quantity' => $this->maximumQuantity, + 'allowed_actions' => array_map( + fn (CatalogAction $action): string => $action->value, + $this->allowedActions, + ), + 'reasons' => $this->reasons, + ]; + } +} diff --git a/app/Domains/Catalog/Services/AvailabilityPolicyResolver.php b/app/Domains/Catalog/Services/AvailabilityPolicyResolver.php new file mode 100644 index 0000000..a234d98 --- /dev/null +++ b/app/Domains/Catalog/Services/AvailabilityPolicyResolver.php @@ -0,0 +1,26 @@ +} */ + public function resolve(string $restrictionCode): array + { + /** @var array{effect?: string, denied_actions?: list} $configured */ + $configured = config("catalog.availability.rules.{$restrictionCode}", []); + + return [ + 'effect' => AvailabilityEffect::from( + $configured['effect'] ?? AvailabilityEffect::Notice->value, + ), + 'denied_actions' => array_map( + fn (string $action): CatalogAction => CatalogAction::from($action), + $configured['denied_actions'] ?? [], + ), + ]; + } +} diff --git a/app/Domains/Catalog/Services/CatalogItemAllowanceService.php b/app/Domains/Catalog/Services/CatalogItemAllowanceService.php index f5575c3..bd9861b 100644 --- a/app/Domains/Catalog/Services/CatalogItemAllowanceService.php +++ b/app/Domains/Catalog/Services/CatalogItemAllowanceService.php @@ -2,10 +2,10 @@ namespace App\Domains\Catalog\Services; -use App\Domains\Catalog\Enums\AvailabilitySubject; +use App\Domains\Catalog\Enums\AvailabilityEffect; +use App\Domains\Catalog\Enums\CatalogAction; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Purchase\Services\UserPurchaseLimitService; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; class CatalogItemAllowanceService @@ -16,6 +16,7 @@ class CatalogItemAllowanceService public function __construct( private readonly UserPurchaseLimitService $purchaseLimits, + private readonly AvailabilityPolicyResolver $policies, ) {} /** @param Collection $catalogItems */ @@ -44,102 +45,82 @@ 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{display: bool, select_variant: bool, change_quantity: bool, add_to_cart: bool, buy_now: bool} - * } - */ public function availability( ?int $availableStock, ?int $remainingUserQuota, - AvailabilitySubject $subject = AvailabilitySubject::Product, - ): array { - $restrictions = []; + ): AvailabilityDecision { + $reasons = []; if ($remainingUserQuota !== null && $remainingUserQuota <= 0) { - $restrictions[] = [ + $reasons[] = [ 'code' => 'user_quota_reached', 'message' => self::USER_QUOTA_REACHED_MESSAGE, ]; } if ($availableStock !== null && $availableStock <= 0) { - $restrictions[] = [ + $reasons[] = [ 'code' => 'out_of_stock', 'message' => self::OUT_OF_STOCK_MESSAGE, ]; } - $capabilities = [ - 'display' => true, - 'select_variant' => true, - 'change_quantity' => true, - 'add_to_cart' => true, - 'buy_now' => true, - ]; - - foreach ($restrictions as $restriction) { - /** @var array $policy */ - $policy = config( - "catalog.availability_policies.{$restriction['code']}.{$subject->value}", - [], - ); - - foreach ($capabilities as $capability => $allowed) { - $capabilities[$capability] = $allowed && ($policy[$capability] ?? true); - } - } - - return [ - 'subject' => $subject->value, - 'maximum_quantity' => $this->maximumAddableQuantity( - $availableStock, - $remainingUserQuota, - ), - 'restrictions' => $restrictions, - 'capabilities' => $capabilities, - ]; + return $this->decision( + $this->maximumAddableQuantity($availableStock, $remainingUserQuota), + $reasons, + ); } - /** @return array */ - public function purchaseLimitExceededAvailability(int $maximumQuantity, string $message): array - { - $availability = $this->availability(null, $maximumQuantity); + public function purchaseLimitExceededAvailability( + int $maximumQuantity, + string $message, + ): AvailabilityDecision { + $reasons = []; - if ($maximumQuantity > 0) { - $availability['restrictions'][] = [ + if ($maximumQuantity <= 0) { + $reasons[] = [ + 'code' => 'user_quota_reached', + 'message' => self::USER_QUOTA_REACHED_MESSAGE, + ]; + } else { + $reasons[] = [ 'code' => 'requested_quantity_exceeds_user_quota', 'message' => $message, ]; } - return $availability; + return $this->decision($maximumQuantity, $reasons); + } + + /** + * @param list $reasons + */ + private function decision(?int $maximumQuantity, array $reasons): AvailabilityDecision + { + /** @var list $configuredActions */ + $configuredActions = config('catalog.availability.default_actions', []); + $allowedActions = collect($configuredActions) + ->map(fn (string $action): CatalogAction => CatalogAction::from($action)); + + foreach ($reasons as $reason) { + $policy = $this->policies->resolve($reason['code']); + + if ($policy['effect'] === AvailabilityEffect::Hide) { + return AvailabilityDecision::hidden($reasons); + } + + if ($policy['effect'] === AvailabilityEffect::Restrict) { + $deniedActions = $policy['denied_actions']; + $allowedActions = $allowedActions->reject( + fn (CatalogAction $action): bool => in_array($action, $deniedActions, true), + ); + } + } + + return AvailabilityDecision::visible( + $maximumQuantity, + $allowedActions->values()->all(), + $reasons, + ); } } diff --git a/app/Domains/Catalog/Services/VariantSelectionService.php b/app/Domains/Catalog/Services/VariantSelectionService.php index 581773c..bae3ff5 100644 --- a/app/Domains/Catalog/Services/VariantSelectionService.php +++ b/app/Domains/Catalog/Services/VariantSelectionService.php @@ -10,6 +10,10 @@ use Illuminate\Support\Collection; class VariantSelectionService { + public function __construct( + private readonly CatalogItemAllowanceService $allowances, + ) {} + /** * @param array $selectedValues * @return array @@ -188,13 +192,17 @@ class VariantSelectionService /** @return array */ private function variantData(CatalogItem $catalogItem, Variant $variant): array { + $availableStock = $catalogItem->inventory_policy === InventoryPolicy::Unlimited + ? null + : $variant->inventory?->availableStock(); + return [ 'id' => $variant->id, 'descripcion' => $variant->getDescription(), 'precio' => number_format($variant->getPrice(), 2, '.', ''), - 'stock_tecnico' => $catalogItem->inventory_policy === InventoryPolicy::Unlimited - ? null - : $variant->inventory?->availableStock(), + 'availability' => $this->allowances + ->availability($availableStock, null) + ->toArray(), 'values' => $variant->selectorOptions($catalogItem->itemAttributes), ]; } diff --git a/bootstrap/app.php b/bootstrap/app.php index 2189296..916944e 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -105,7 +105,7 @@ return Application::configure(basePath: dirname(__DIR__)) ->purchaseLimitExceededAvailability( $exception->maximumAddableQuantity, $exception->getMessage(), - ), + )->toArray(), ], 422); }); $exceptions->render(function (PurchaseExpiredException $exception, Request $request) { diff --git a/config/catalog.php b/config/catalog.php index 831f22a..f41038f 100644 --- a/config/catalog.php +++ b/config/catalog.php @@ -2,37 +2,32 @@ return [ 'stock_reservation_expiration_minutes' => (int) env('STOCK_RESERVATION_EXPIRATION_MINUTES', 30), - 'availability_policies' => [ - 'user_quota_reached' => [ - '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, - ], + 'availability' => [ + 'default_actions' => [ + 'select_variant', + 'change_quantity', + 'add_to_cart', + 'buy_now', ], - 'out_of_stock' => [ - 'product' => [ - 'display' => false, - 'select_variant' => false, - 'change_quantity' => false, - 'add_to_cart' => false, - 'buy_now' => false, + 'rules' => [ + 'user_quota_reached' => [ + 'effect' => 'restrict', + 'denied_actions' => [ + 'select_variant', + 'change_quantity', + 'add_to_cart', + 'buy_now', + ], ], - 'variant' => [ - 'display' => false, - 'select_variant' => false, - 'change_quantity' => false, - 'add_to_cart' => false, - 'buy_now' => false, + 'out_of_stock' => [ + 'effect' => 'hide', + ], + 'requested_quantity_exceeds_user_quota' => [ + 'effect' => 'restrict', + 'denied_actions' => [ + 'add_to_cart', + 'buy_now', + ], ], ], ],