diff --git a/app/Domains/Cart/Models/Cart.php b/app/Domains/Cart/Models/Cart.php index 6032b94..4c344a4 100644 --- a/app/Domains/Cart/Models/Cart.php +++ b/app/Domains/Cart/Models/Cart.php @@ -369,6 +369,7 @@ class Cart extends Model $this->user_id, $cartQuantity, $excludedPurchaseId, + $this->getKey(), field: 'cantidad', ); } diff --git a/app/Domains/Catalog/Controllers/CatalogController.php b/app/Domains/Catalog/Controllers/CatalogController.php index 9d75e12..e949d00 100644 --- a/app/Domains/Catalog/Controllers/CatalogController.php +++ b/app/Domains/Catalog/Controllers/CatalogController.php @@ -17,17 +17,20 @@ use App\Domains\Catalog\Resources\CatalogItemDetailResource; use App\Domains\Catalog\Resources\CatalogItemResource; use App\Domains\Catalog\Resources\CatalogSearchItemResource; use App\Domains\Catalog\Resources\CatalogVariantOptionsResource; +use App\Domains\Catalog\Services\CatalogItemAllowanceService; use App\Domains\Catalog\Services\CatalogService; use App\Domains\Catalog\Services\FeaturedGroupService; use App\Domains\Catalog\Services\VariantSelectionService; use App\Domains\Tenant\Models\Tenant; use App\Http\Controllers\Controller; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; +use Illuminate\Support\Facades\Auth; class CatalogController extends Controller { - public function index(Tenant $tenant, FeaturedGroupService $featuredGroupService): JsonResponse + public function index(Request $request, Tenant $tenant, FeaturedGroupService $featuredGroupService): JsonResponse { $featuredGroups = FeaturedGroup::query() ->where('tenant_code', $tenant->codigo) @@ -37,7 +40,7 @@ class CatalogController extends Controller return response()->json($featuredGroups->map( fn (FeaturedGroup $featuredGroup): array => (new CatalogFeaturedGroupResource( $featuredGroup, - $featuredGroupService->itemsResponse($featuredGroup, 1), + $featuredGroupService->itemsResponse($featuredGroup, 1, $this->userId($request)), ))->resolve() )); } @@ -46,15 +49,17 @@ class CatalogController extends Controller SearchCatalogItemsRequest $request, Tenant $tenant, CatalogService $catalogService, + CatalogItemAllowanceService $allowances, ): AnonymousResourceCollection { - return CatalogSearchItemResource::collection( - $catalogService->search( - $tenant, - $request->validated('q'), - $tenant->search_items_per_page, - (int) $request->validated('page', 1), - ) + $items = $catalogService->search( + $tenant, + $request->validated('q'), + $tenant->search_items_per_page, + (int) $request->validated('page', 1), ); + $allowances->attach($items->getCollection(), $this->userId($request)); + + return CatalogSearchItemResource::collection($items); } public function category( @@ -62,17 +67,19 @@ class CatalogController extends Controller Tenant $tenant, Category $category, CatalogService $catalogService, + CatalogItemAllowanceService $allowances, ): AnonymousResourceCollection { abort_unless($category->tenant_code === $tenant->codigo, 404); - return CatalogSearchItemResource::collection( - $catalogService->categoryItems( - $tenant, - $category, - $tenant->search_items_per_page, - (int) $request->validated('page', 1), - ) - )->additional([ + $items = $catalogService->categoryItems( + $tenant, + $category, + $tenant->search_items_per_page, + (int) $request->validated('page', 1), + ); + $allowances->attach($items->getCollection(), $this->userId($request)); + + return CatalogSearchItemResource::collection($items)->additional([ 'category' => [ 'id' => $category->id, 'nombre' => $category->nombre, @@ -93,7 +100,11 @@ class CatalogController extends Controller $page = (int) $request->validated('page', 1); - return response()->json($featuredGroupService->itemsResponse($featuredGroup, $page)); + return response()->json($featuredGroupService->itemsResponse( + $featuredGroup, + $page, + $this->userId($request), + )); } public function show( @@ -101,17 +112,19 @@ class CatalogController extends Controller Tenant $tenant, CatalogItem $catalogItem, CatalogService $catalogService, + CatalogItemAllowanceService $allowances, ): CatalogItemDetailResource { abort_unless($catalogItem->tenant_code === $tenant->codigo, 404); $variantId = $request->validated('variant_id'); - return CatalogItemDetailResource::make( - $catalogService->getDetail( - $catalogItem, - $variantId === null ? null : (int) $variantId, - ) + $item = $catalogService->getDetail( + $catalogItem, + $variantId === null ? null : (int) $variantId, ); + $allowances->attach(collect([$item]), $this->userId($request)); + + return CatalogItemDetailResource::make($item); } public function variantOptions( @@ -164,4 +177,11 @@ class CatalogController extends Controller ->response() ->setStatusCode(201); } + + private function userId(Request $request): ?int + { + $userId = $request->user()?->getAuthIdentifier() ?? Auth::guard('sanctum')->id(); + + return $userId === null ? null : (int) $userId; + } } diff --git a/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php b/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php index 7920780..0845d69 100644 --- a/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php +++ b/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php @@ -7,6 +7,7 @@ use App\Domains\Catalog\Enums\ProductLayout; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\FeaturedGroup; use App\Domains\Catalog\Models\Variant; +use App\Domains\Catalog\Services\CatalogItemAllowanceService; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -19,6 +20,7 @@ class CatalogFeaturedItemResource extends JsonResource $catalogItem = $this->resource; /** @var FeaturedGroup $featuredGroup */ $featuredGroup = $catalogItem->getRelation('featuredGroup'); + $remainingUserQuota = $catalogItem->getAttribute('remaining_user_quota'); if ($featuredGroup->product_layout === ProductLayout::ColumnWithImage) { return $this->columnWithImageData($catalogItem); @@ -35,6 +37,10 @@ class CatalogFeaturedItemResource extends JsonResource 'descripcion' => $catalogItem->descripcion, 'precio' => $catalogItem->precio, 'stock_tecnico' => $catalogItem->availableStock(), + 'maximum_addable_quantity' => $this->maximumAddable( + $catalogItem->availableStock(), + $remainingUserQuota, + ), 'variants' => $catalogItem->visibleVariants() ->map(fn (Variant $variant): array => [ 'id' => $variant->id, @@ -47,6 +53,12 @@ class CatalogFeaturedItemResource extends JsonResource 'stock_tecnico' => $catalogItem->inventory_policy === InventoryPolicy::Unlimited ? null : $variant->inventory->availableStock(), + 'maximum_addable_quantity' => $this->maximumAddable( + $catalogItem->inventory_policy === InventoryPolicy::Unlimited + ? null + : $variant->inventory->availableStock(), + $remainingUserQuota, + ), 'values' => $variant->selectorOptions($catalogItem->itemAttributes), ]) ->values(), @@ -89,4 +101,10 @@ class CatalogFeaturedItemResource extends JsonResource return $attachment?->getTemporaryUrl(1440); } + + private function maximumAddable(?int $stock, ?int $remainingUserQuota): ?int + { + return app(CatalogItemAllowanceService::class) + ->maximumAddableQuantity($stock, $remainingUserQuota); + } } diff --git a/app/Domains/Catalog/Resources/CatalogItemDetailResource.php b/app/Domains/Catalog/Resources/CatalogItemDetailResource.php index f89422f..8f7cf7f 100644 --- a/app/Domains/Catalog/Resources/CatalogItemDetailResource.php +++ b/app/Domains/Catalog/Resources/CatalogItemDetailResource.php @@ -6,6 +6,7 @@ use App\Domains\Catalog\Enums\InventoryPolicy; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\ItemAttribute; use App\Domains\Catalog\Models\Variant; +use App\Domains\Catalog\Services\CatalogItemAllowanceService; use App\Domains\Shared\Enums\FieldType; use App\Domains\Ticket\Resources\ValidityTimeResource; use Illuminate\Http\Request; @@ -41,6 +42,10 @@ class CatalogItemDetailResource extends JsonResource $selectedVariant === null, fn () => $this->availableStock(), ), + 'maximum_addable_quantity' => $this->when( + $selectedVariant === null, + fn () => $this->maximumAddable($this->availableStock()), + ), 'images' => $this->when( $selectedVariant === null, fn () => $this->imageUrls($this->attachments), @@ -168,6 +173,7 @@ class CatalogItemDetailResource extends JsonResource 'descripcion' => $variant->getDescription(), 'precio' => number_format($variant->getPrice(), 2, '.', ''), 'stock_tecnico' => $this->variantStock($variant), + 'maximum_addable_quantity' => $this->maximumAddable($this->variantStock($variant)), 'values' => $values, ]; } @@ -186,4 +192,12 @@ class CatalogItemDetailResource extends JsonResource ? null : $variant->inventory->availableStock(); } + + private function maximumAddable(?int $stock): ?int + { + return app(CatalogItemAllowanceService::class)->maximumAddableQuantity( + $stock, + $this->getAttribute('remaining_user_quota'), + ); + } } diff --git a/app/Domains/Catalog/Resources/CatalogSearchItemResource.php b/app/Domains/Catalog/Resources/CatalogSearchItemResource.php index 4c6cd72..c75fafb 100644 --- a/app/Domains/Catalog/Resources/CatalogSearchItemResource.php +++ b/app/Domains/Catalog/Resources/CatalogSearchItemResource.php @@ -5,6 +5,7 @@ namespace App\Domains\Catalog\Resources; use App\Domains\Catalog\Enums\InventoryPolicy; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Variant; +use App\Domains\Catalog\Services\CatalogItemAllowanceService; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -27,6 +28,7 @@ class CatalogSearchItemResource extends JsonResource 'precio' => $this->precio, 'image' => $attachment?->getTemporaryUrl(1440), 'stock_tecnico' => $this->availableStock(), + 'maximum_addable_quantity' => $this->maximumAddable($this->availableStock()), 'variants' => $this->visibleVariants() ->map(fn (Variant $variant): array => [ 'id' => $variant->id, @@ -39,9 +41,22 @@ class CatalogSearchItemResource extends JsonResource 'stock_tecnico' => $this->inventory_policy === InventoryPolicy::Unlimited ? null : $variant->inventory?->availableStock(), + 'maximum_addable_quantity' => $this->maximumAddable( + $this->inventory_policy === InventoryPolicy::Unlimited + ? null + : $variant->inventory?->availableStock(), + ), 'values' => $variant->selectorOptions($this->itemAttributes), ]) ->values(), ]; } + + private function maximumAddable(?int $stock): ?int + { + return app(CatalogItemAllowanceService::class)->maximumAddableQuantity( + $stock, + $this->getAttribute('remaining_user_quota'), + ); + } } diff --git a/app/Domains/Catalog/Services/CatalogItemAllowanceService.php b/app/Domains/Catalog/Services/CatalogItemAllowanceService.php new file mode 100644 index 0000000..6070c55 --- /dev/null +++ b/app/Domains/Catalog/Services/CatalogItemAllowanceService.php @@ -0,0 +1,40 @@ + $catalogItems */ + public function attach(Collection $catalogItems, ?int $userId): void + { + $remaining = $this->purchaseLimits->remainingByCatalogItem($catalogItems, $userId); + + foreach ($catalogItems as $catalogItem) { + $catalogItem->setAttribute( + 'remaining_user_quota', + $remaining->get($catalogItem->getKey()), + ); + } + } + + public function maximumAddableQuantity(?int $availableStock, ?int $remainingUserQuota): ?int + { + if ($availableStock === null) { + return $remainingUserQuota; + } + + if ($remainingUserQuota === null) { + return $availableStock; + } + + return min($availableStock, $remainingUserQuota); + } +} diff --git a/app/Domains/Catalog/Services/FeaturedGroupService.php b/app/Domains/Catalog/Services/FeaturedGroupService.php index 970e063..7f9258e 100644 --- a/app/Domains/Catalog/Services/FeaturedGroupService.php +++ b/app/Domains/Catalog/Services/FeaturedGroupService.php @@ -15,7 +15,11 @@ class FeaturedGroupService private const ITEMS_PER_PAGE = 12; /** @return array */ - public function itemsResponse(FeaturedGroup $featuredGroup, int $page): array + public function __construct( + private readonly CatalogItemAllowanceService $allowances, + ) {} + + public function itemsResponse(FeaturedGroup $featuredGroup, int $page, ?int $userId = null): array { if ($featuredGroup->group_layout !== GroupLayout::Paginated) { $query = $this->itemsQuery($featuredGroup); @@ -26,12 +30,14 @@ class FeaturedGroupService $items = $query->get(); $this->attachGroup($items, $featuredGroup); + $this->allowances->attach($items, $userId); return CatalogFeaturedItemResource::collection($items)->resolve(); } $paginator = $this->paginateItems($featuredGroup, $page); $this->attachGroup($paginator->getCollection(), $featuredGroup); + $this->allowances->attach($paginator->getCollection(), $userId); return CatalogFeaturedItemResource::collection($paginator) ->response() diff --git a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php index 9e75ce1..87a0b59 100644 --- a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php @@ -243,7 +243,7 @@ class StartCheckoutService $this->loadCartItems($cartItems); $this->verifyTenantItems($tenant, $cartItems); - $this->assertCartPurchaseLimits($tenant, $userId, $cartItems); + $this->assertCartPurchaseLimits($tenant, $userId, $cartItems, $cart->getKey()); $cart->setRelation('items', $cartItems); $purchase = $this->createPurchase( @@ -312,6 +312,7 @@ class StartCheckoutService Tenant $tenant, int $userId, Collection $cartItems, + int $cartId, ): void { $quantities = $cartItems ->groupBy('catalog_item_id') @@ -333,6 +334,7 @@ class StartCheckoutService $catalogItem, $userId, $quantity, + excludedCartId: $cartId, field: 'cart_id', ); } diff --git a/app/Domains/Purchase/Services/UserPurchaseLimitService.php b/app/Domains/Purchase/Services/UserPurchaseLimitService.php index c45d096..2a1b5e5 100644 --- a/app/Domains/Purchase/Services/UserPurchaseLimitService.php +++ b/app/Domains/Purchase/Services/UserPurchaseLimitService.php @@ -6,6 +6,7 @@ use App\Domains\Cart\Models\CartItem; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\PurchaseItem; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; @@ -16,6 +17,7 @@ class UserPurchaseLimitService int $userId, int $requestedQuantity, ?int $excludedPurchaseId = null, + ?int $excludedCartId = null, string $field = 'quantity', ): void { DB::transaction(function () use ( @@ -23,6 +25,7 @@ class UserPurchaseLimitService $userId, $requestedQuantity, $excludedPurchaseId, + $excludedCartId, $field, ): void { /** @var CatalogItem $catalogItem */ @@ -70,11 +73,84 @@ class UserPurchaseLimitService }) ->sum('cantidad'); - if ($purchasedQuantity + $checkoutQuantity + $requestedQuantity > $limit) { + $reservedCartQuantity = (int) CartItem::query() + ->where('catalog_item_id', $catalogItem->getKey()) + ->whereHas('cart', fn ($query) => $query + ->where('user_id', $userId) + ->where('status', 'active') + ->when( + $excludedCartId !== null, + fn ($query) => $query->whereKeyNot($excludedCartId), + )) + ->whereHas('stockReservations', fn ($query) => $query->where('status', 'active')) + ->sum('cantidad'); + + if ($purchasedQuantity + $checkoutQuantity + $reservedCartQuantity + $requestedQuantity > $limit) { throw ValidationException::withMessages([ $field => __('api.purchase_limit.exceeded', ['max' => $limit]), ]); } }); } + + /** + * @param Collection $catalogItems + * @return Collection + */ + public function remainingByCatalogItem(Collection $catalogItems, ?int $userId): Collection + { + $limits = $catalogItems + ->unique('id') + ->mapWithKeys(fn (CatalogItem $item): array => [$item->getKey() => $item->max_units_per_user]); + + if ($userId === null || $limits->filter(fn ($limit) => $limit !== null)->isEmpty()) { + return $limits->map(fn (): ?int => null); + } + + $ids = $limits->keys(); + $purchased = PurchaseItem::query() + ->selectRaw('source_catalog_item_id, SUM(cantidad) AS quantity') + ->whereIn('source_catalog_item_id', $ids) + ->whereHas('purchase', fn ($query) => $query + ->where('user_id', $userId) + ->whereIn('status', [ + Purchase::STATUS_CREATED, + Purchase::STATUS_PENDING_PAYMENT, + Purchase::STATUS_PAID, + ])) + ->groupBy('source_catalog_item_id') + ->pluck('quantity', 'source_catalog_item_id'); + + $checkout = CartItem::query() + ->selectRaw('catalog_item_id, SUM(cantidad) AS quantity') + ->whereIn('catalog_item_id', $ids) + ->whereHas('cart.purchases', fn ($query) => $query + ->where('user_id', $userId) + ->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT]) + ->whereDoesntHave('items')) + ->groupBy('catalog_item_id') + ->pluck('quantity', 'catalog_item_id'); + + $reserved = CartItem::query() + ->selectRaw('catalog_item_id, SUM(cantidad) AS quantity') + ->whereIn('catalog_item_id', $ids) + ->whereHas('cart', fn ($query) => $query + ->where('user_id', $userId) + ->where('status', 'active')) + ->whereHas('stockReservations', fn ($query) => $query->where('status', 'active')) + ->groupBy('catalog_item_id') + ->pluck('quantity', 'catalog_item_id'); + + return $limits->map(function (?int $limit, int $catalogItemId) use ($purchased, $checkout, $reserved): ?int { + if ($limit === null) { + return null; + } + + $used = (int) ($purchased[$catalogItemId] ?? 0) + + (int) ($checkout[$catalogItemId] ?? 0) + + (int) ($reserved[$catalogItemId] ?? 0); + + return max(0, $limit - $used); + }); + } } diff --git a/tests/Feature/Catalog/CatalogControllerTest.php b/tests/Feature/Catalog/CatalogControllerTest.php index 38ffe48..29c8b96 100644 --- a/tests/Feature/Catalog/CatalogControllerTest.php +++ b/tests/Feature/Catalog/CatalogControllerTest.php @@ -4,6 +4,8 @@ namespace Tests\Feature\Catalog; use App\Domains\Attachable\Enums\AttachmentType; use App\Domains\Attachable\Models\Attachment; +use App\Domains\Auth\Models\User; +use App\Domains\Cart\Models\Cart; use App\Domains\Catalog\Enums\FeaturedGroupSource; use App\Domains\Catalog\Enums\GroupLayout; use App\Domains\Catalog\Enums\ProductLayout; @@ -73,13 +75,53 @@ class CatalogControllerTest extends TestCase ->assertJsonPath('0.items.0.stock_tecnico', 7) ->assertJsonCount(2, '0.items.0.variants') ->assertJsonPath('0.items.0.variants.0.stock_tecnico', 4) + ->assertJsonPath('0.items.0.variants.0.maximum_addable_quantity', 4) ->assertJsonPath('0.items.0.variants.1.stock_tecnico', 3) + ->assertJsonPath('0.items.0.variants.1.maximum_addable_quantity', 3) ->assertJsonMissing(['id' => $unavailableVariant->id, 'stock_tecnico' => 0]) ->assertJsonPath('1.title', 'Row') ->assertJsonPath('1.items.data.0.stock_tecnico', 8) + ->assertJsonPath('1.items.data.0.maximum_addable_quantity', 8) ->assertJsonCount(0, '1.items.data.0.variants'); } + public function test_maximum_addable_quantity_shares_the_authenticated_user_quota_between_variants(): void + { + $tenant = $this->createTenant('catalog-allowance'); + $group = $this->createGroup( + $tenant, + ProductLayout::ColumnWithCart, + 'Allowances', + groupLayout: GroupLayout::Simple, + ); + $user = User::factory()->create(); + $item = $this->createItem($tenant, 'Limited variants'); + $item->update(['max_units_per_user' => 5]); + $firstVariant = $item->variants()->create([ + 'inventory_id' => Inventory::query()->create(['real_stock' => 10])->id, + ]); + $secondVariant = $item->variants()->create([ + 'inventory_id' => Inventory::query()->create(['real_stock' => 10])->id, + ]); + $group->featuredItems()->create(['catalog_item_id' => $item->id]); + + $cart = Cart::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'user_id' => $user->id, + 'status' => 'active', + ]); + $cart->addItem($item->id, $firstVariant->id, 2); + $cart->addItem($item->id, $secondVariant->id, 1); + + $this->actingAs($user, 'sanctum') + ->getJson("/api/tenants/{$tenant->codigo}/catalog") + ->assertOk() + ->assertJsonPath('0.items.0.variants.0.stock_tecnico', 8) + ->assertJsonPath('0.items.0.variants.0.maximum_addable_quantity', 2) + ->assertJsonPath('0.items.0.variants.1.stock_tecnico', 9) + ->assertJsonPath('0.items.0.variants.1.maximum_addable_quantity', 2); + } + public function test_it_excludes_items_when_all_of_their_variants_are_out_of_stock(): void { $tenant = $this->createTenant('catalog-available-variants');