feat(catalog): implement maximum addable quantity logic and user quota sharing across variants

This commit is contained in:
2026-08-19 16:43:51 -03:00
parent d97cc12af6
commit 8537d2ed0a
10 changed files with 260 additions and 26 deletions

View File

@@ -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;
}
}

View File

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

View File

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

View File

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

View 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);
}
}

View File

@@ -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()