feat(catalog): implement maximum addable quantity logic and user quota sharing across variants
This commit is contained in:
@@ -369,6 +369,7 @@ class Cart extends Model
|
||||
$this->user_id,
|
||||
$cartQuantity,
|
||||
$excludedPurchaseId,
|
||||
$this->getKey(),
|
||||
field: 'cantidad',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
40
app/Domains/Catalog/Services/CatalogItemAllowanceService.php
Normal file
40
app/Domains/Catalog/Services/CatalogItemAllowanceService.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Services;
|
||||
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Purchase\Services\UserPurchaseLimitService;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class CatalogItemAllowanceService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserPurchaseLimitService $purchaseLimits,
|
||||
) {}
|
||||
|
||||
/** @param Collection<int, CatalogItem> $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);
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,11 @@ class FeaturedGroupService
|
||||
private const ITEMS_PER_PAGE = 12;
|
||||
|
||||
/** @return array<array-key, mixed> */
|
||||
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()
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<int, CatalogItem> $catalogItems
|
||||
* @return Collection<int, int|null>
|
||||
*/
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user