refactor(backend): reorganize domains into Core, Commerce, Ticketing and Shared
This commit is contained in:
@@ -1,254 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Services;
|
||||
|
||||
use App\Domains\Catalog\Enums\CatalogItemType;
|
||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||
use App\Domains\Catalog\Models\BundleComponent;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Inventory;
|
||||
use App\Domains\Catalog\Models\Variant;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class CatalogInventoryService
|
||||
{
|
||||
/** @return array<int, int> */
|
||||
public function requirementsFor(CatalogItem|Variant $selection, int $quantity = 1): array
|
||||
{
|
||||
if ($quantity <= 0) {
|
||||
throw new \InvalidArgumentException('La cantidad debe ser mayor a cero.');
|
||||
}
|
||||
|
||||
return array_map(
|
||||
fn (array $requirement): int => $requirement['quantity'] * $quantity,
|
||||
$this->inventoryRequirements($selection),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{quantity: int, tracks_inventory: bool}>
|
||||
*/
|
||||
public function detailedRequirementsFor(CatalogItem|Variant $selection, int $quantity = 1): array
|
||||
{
|
||||
if ($quantity <= 0) {
|
||||
throw new \InvalidArgumentException('La cantidad debe ser mayor a cero.');
|
||||
}
|
||||
|
||||
return array_map(
|
||||
fn (array $requirement): array => [
|
||||
...$requirement,
|
||||
'quantity' => $requirement['quantity'] * $quantity,
|
||||
],
|
||||
$this->inventoryRequirements($selection),
|
||||
);
|
||||
}
|
||||
|
||||
public function availableQuantity(CatalogItem|Variant $selection): ?int
|
||||
{
|
||||
if ($selection instanceof CatalogItem
|
||||
&& $selection->type === CatalogItemType::Standard
|
||||
&& $selection->relationLoaded('inventory')
|
||||
&& $selection->inventory !== null) {
|
||||
return $selection->inventory_policy === InventoryPolicy::Unlimited
|
||||
? null
|
||||
: $selection->inventory->availableStock();
|
||||
}
|
||||
|
||||
if ($selection instanceof CatalogItem
|
||||
&& $selection->type === CatalogItemType::Standard
|
||||
&& $selection->inventory_id === null) {
|
||||
if ($selection->inventory_policy === InventoryPolicy::Unlimited) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$selection->loadMissing('variants.inventory');
|
||||
|
||||
return $selection->variants
|
||||
->filter(fn (Variant $variant): bool => $variant->isSellable())
|
||||
->unique(fn (Variant $variant): string => $variant->inventory_id === null
|
||||
? 'object:'.spl_object_id($variant->inventory)
|
||||
: 'id:'.$variant->inventory_id)
|
||||
->sum(
|
||||
fn (Variant $variant): int => $variant->inventory->availableStock(),
|
||||
);
|
||||
}
|
||||
|
||||
$requirements = $this->inventoryRequirements($selection);
|
||||
$trackedRequirements = array_filter(
|
||||
$requirements,
|
||||
fn (array $requirement): bool => $requirement['tracks_inventory'],
|
||||
);
|
||||
|
||||
if ($trackedRequirements === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$inventories = Inventory::query()
|
||||
->whereKey(array_keys($trackedRequirements))
|
||||
->get()
|
||||
->keyBy('id');
|
||||
$available = [];
|
||||
|
||||
foreach ($trackedRequirements as $inventoryId => $requirement) {
|
||||
$inventory = $inventories->get($inventoryId)
|
||||
?? throw new \InvalidArgumentException('No se encontro el inventario requerido.');
|
||||
$available[] = intdiv(
|
||||
$inventory->availableStock(),
|
||||
$requirement['quantity'],
|
||||
);
|
||||
}
|
||||
|
||||
return min($available);
|
||||
}
|
||||
|
||||
public function reserve(CatalogItem|Variant $selection, int $quantity): void
|
||||
{
|
||||
$this->mutate($selection, $quantity, 'reserve');
|
||||
}
|
||||
|
||||
public function release(CatalogItem|Variant $selection, int $quantity): void
|
||||
{
|
||||
$this->mutate($selection, $quantity, 'release');
|
||||
}
|
||||
|
||||
public function commit(CatalogItem|Variant $selection, int $quantity): void
|
||||
{
|
||||
$this->mutate($selection, $quantity, 'commit');
|
||||
}
|
||||
|
||||
private function mutate(
|
||||
CatalogItem|Variant $selection,
|
||||
int $quantity,
|
||||
string $operation,
|
||||
): void {
|
||||
if ($quantity <= 0) {
|
||||
throw new \InvalidArgumentException('La cantidad debe ser mayor a cero.');
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($selection, $quantity, $operation): void {
|
||||
$requirements = $this->inventoryRequirements($selection);
|
||||
ksort($requirements);
|
||||
$inventories = Inventory::query()
|
||||
->whereKey(array_keys($requirements))
|
||||
->orderBy('id')
|
||||
->lockForUpdate()
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
foreach ($requirements as $inventoryId => $requirement) {
|
||||
$inventory = $inventories->get($inventoryId)
|
||||
?? throw new \InvalidArgumentException('No se encontro el inventario requerido.');
|
||||
$requiredQuantity = $requirement['quantity'] * $quantity;
|
||||
|
||||
if ($operation === 'reserve'
|
||||
&& $requirement['tracks_inventory']
|
||||
&& $inventory->availableStock() < $requiredQuantity) {
|
||||
throw new \InvalidArgumentException('No hay suficiente stock disponible para reservar.');
|
||||
}
|
||||
|
||||
if (in_array($operation, ['release', 'commit'], true)
|
||||
&& $inventory->reserved_stock < $requiredQuantity) {
|
||||
throw new \InvalidArgumentException('La cantidad reservada no alcanza para la operacion.');
|
||||
}
|
||||
|
||||
if ($operation === 'commit'
|
||||
&& $requirement['tracks_inventory']
|
||||
&& $inventory->real_stock < $requiredQuantity) {
|
||||
throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la compra.');
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($requirements as $inventoryId => $requirement) {
|
||||
/** @var Inventory $inventory */
|
||||
$inventory = $inventories->get($inventoryId);
|
||||
$requiredQuantity = $requirement['quantity'] * $quantity;
|
||||
|
||||
match ($operation) {
|
||||
'reserve' => $inventory->reserve($requiredQuantity, $requirement['tracks_inventory']),
|
||||
'release' => $inventory->release($requiredQuantity),
|
||||
'commit' => $inventory->buy($requiredQuantity, $requirement['tracks_inventory']),
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{quantity: int, tracks_inventory: bool}>
|
||||
*/
|
||||
private function inventoryRequirements(CatalogItem|Variant $selection): array
|
||||
{
|
||||
if ($selection instanceof Variant) {
|
||||
$selection->loadMissing('catalogItem');
|
||||
|
||||
return $this->singleRequirement(
|
||||
$selection->inventory_id,
|
||||
$selection->catalogItem->inventory_policy,
|
||||
);
|
||||
}
|
||||
|
||||
if ($selection->type !== CatalogItemType::Bundle) {
|
||||
return $this->singleRequirement(
|
||||
$selection->inventory_id,
|
||||
$selection->inventory_policy,
|
||||
);
|
||||
}
|
||||
|
||||
$selection->loadMissing([
|
||||
'bundleComponents.catalogItem',
|
||||
'bundleComponents.variant.catalogItem',
|
||||
]);
|
||||
$requirements = [];
|
||||
|
||||
foreach ($selection->bundleComponents as $component) {
|
||||
$this->addComponentRequirement($requirements, $component);
|
||||
}
|
||||
|
||||
if ($requirements === []) {
|
||||
throw new \InvalidArgumentException('El bundle no tiene componentes.');
|
||||
}
|
||||
|
||||
return $requirements;
|
||||
}
|
||||
|
||||
/** @return array<int, array{quantity: int, tracks_inventory: bool}> */
|
||||
private function singleRequirement(
|
||||
?int $inventoryId,
|
||||
?InventoryPolicy $policy,
|
||||
): array {
|
||||
if ($inventoryId === null || $policy === null) {
|
||||
throw new \InvalidArgumentException('El item requiere una variante con inventario.');
|
||||
}
|
||||
|
||||
return [
|
||||
$inventoryId => [
|
||||
'quantity' => 1,
|
||||
'tracks_inventory' => $policy === InventoryPolicy::Tracked,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{quantity: int, tracks_inventory: bool}> $requirements
|
||||
*/
|
||||
private function addComponentRequirement(array &$requirements, BundleComponent $component): void
|
||||
{
|
||||
$selectedItem = $component->variant ?? $component->catalogItem;
|
||||
$inventoryId = $selectedItem->inventory_id;
|
||||
$policy = $component->catalogItem->inventory_policy;
|
||||
|
||||
if ($inventoryId === null || $policy === null) {
|
||||
throw new \InvalidArgumentException('Un componente del bundle no tiene inventario.');
|
||||
}
|
||||
|
||||
if (isset($requirements[$inventoryId])) {
|
||||
$requirements[$inventoryId]['quantity'] += $component->quantity;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$requirements[$inventoryId] = [
|
||||
'quantity' => $component->quantity,
|
||||
'tracks_inventory' => $policy === InventoryPolicy::Tracked,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Services;
|
||||
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Purchase\Services\UserPurchaseLimitService;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class CatalogItemAllowanceService
|
||||
{
|
||||
private const USER_QUOTA_REACHED_MESSAGE = 'Alcanzaste el cupo máximo permitido para este producto.';
|
||||
|
||||
private const OUT_OF_STOCK_MESSAGE = 'Este producto no tiene stock disponible.';
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public function unavailableMessage(?int $availableStock, ?int $remainingUserQuota): ?string
|
||||
{
|
||||
if ($remainingUserQuota !== null && $remainingUserQuota <= 0) {
|
||||
return self::USER_QUOTA_REACHED_MESSAGE;
|
||||
}
|
||||
|
||||
if ($availableStock !== null && $availableStock <= 0) {
|
||||
return self::OUT_OF_STOCK_MESSAGE;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,778 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Services;
|
||||
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Attachable\Services\AttachmentService;
|
||||
use App\Domains\Catalog\Enums\CatalogItemType;
|
||||
use App\Domains\Catalog\Models\Attribute;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Catalog\Models\Inventory;
|
||||
use App\Domains\Catalog\Models\ItemAttribute;
|
||||
use App\Domains\Catalog\Models\Variant;
|
||||
use App\Domains\Event\Models\EventDate;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class CatalogService
|
||||
{
|
||||
public function __construct(protected AttachmentService $attachmentService) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function create(array $data): CatalogItem
|
||||
{
|
||||
return DB::transaction(function () use ($data): CatalogItem {
|
||||
$type = CatalogItemType::from(
|
||||
$data['type'] ?? CatalogItemType::Standard->value,
|
||||
);
|
||||
$variants = $data['variants'] ?? [];
|
||||
$images = $data['images'] ?? [];
|
||||
$attributeCodes = $data['attribute_codes'] ?? [];
|
||||
$multiSelectAttributeCodes = $data['multi_select_attribute_codes'] ?? [];
|
||||
$hiddenAttributeCodes = $data['hidden_attribute_codes'] ?? [];
|
||||
$ticketAttributeLabels = $data['ticket_attribute_labels'] ?? [];
|
||||
$components = $data['components'] ?? [];
|
||||
$hasDirectStock = array_key_exists('real_stock', $data);
|
||||
$realStock = (int) ($data['real_stock'] ?? 0);
|
||||
|
||||
$hasEventDateVariants = $variants !== [] && collect($variants)->every(
|
||||
fn (array $variant): bool => ! empty($variant['event_date_id'])
|
||||
|| ! empty($variant['event_date_ids'])
|
||||
);
|
||||
|
||||
if ($hasEventDateVariants && ! in_array('event_date', $attributeCodes, true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'attribute_codes' => [
|
||||
__('api.catalog.event_date_attribute_required'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$hasVariants = $attributeCodes !== [] || $hasEventDateVariants;
|
||||
|
||||
if (array_diff($multiSelectAttributeCodes, $attributeCodes) !== []) {
|
||||
throw ValidationException::withMessages([
|
||||
'multi_select_attribute_codes' => [
|
||||
__('api.catalog.multi_select_attribute_not_on_item'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
if (array_diff($hiddenAttributeCodes, $attributeCodes) !== []) {
|
||||
throw ValidationException::withMessages([
|
||||
'hidden_attribute_codes' => [
|
||||
__('api.catalog.hidden_attribute_not_on_item'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
if (array_diff(array_keys($ticketAttributeLabels), $attributeCodes) !== []) {
|
||||
throw ValidationException::withMessages([
|
||||
'ticket_attribute_labels' => [
|
||||
__('api.catalog.ticket_label_attribute_not_on_item'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$this->validateUniqueVariantCombinations($variants, $attributeCodes);
|
||||
|
||||
if ($type === CatalogItemType::Bundle) {
|
||||
$this->validateBundleData($data, $components);
|
||||
} else {
|
||||
if (array_key_exists('components', $data)) {
|
||||
throw ValidationException::withMessages([
|
||||
'components' => [__('api.catalog.standard_with_components')],
|
||||
]);
|
||||
}
|
||||
|
||||
$this->validateInventoryStrategy(
|
||||
$data,
|
||||
$variants,
|
||||
$hasVariants,
|
||||
$hasDirectStock,
|
||||
);
|
||||
}
|
||||
|
||||
unset(
|
||||
$data['variants'],
|
||||
$data['images'],
|
||||
$data['attribute_codes'],
|
||||
$data['multi_select_attribute_codes'],
|
||||
$data['hidden_attribute_codes'],
|
||||
$data['ticket_attribute_labels'],
|
||||
$data['components'],
|
||||
$data['real_stock'],
|
||||
$data['reserved_stock'],
|
||||
$data['sold_units'],
|
||||
$data['inventory_id'],
|
||||
);
|
||||
|
||||
$data['type'] = $type;
|
||||
|
||||
if ($type === CatalogItemType::Bundle) {
|
||||
$data['inventory_id'] = null;
|
||||
$data['inventory_policy'] = null;
|
||||
$data['has_tickets'] = false;
|
||||
} elseif ($hasVariants) {
|
||||
$data['inventory_id'] = null;
|
||||
} else {
|
||||
$data['inventory_id'] = $this->createInventory($realStock)->id;
|
||||
}
|
||||
|
||||
$catalogItem = CatalogItem::query()->create($data);
|
||||
$itemAttributes = $type === CatalogItemType::Standard
|
||||
? $this->createItemAttributes(
|
||||
$catalogItem,
|
||||
$attributeCodes,
|
||||
$multiSelectAttributeCodes,
|
||||
$hiddenAttributeCodes,
|
||||
$ticketAttributeLabels,
|
||||
)
|
||||
: [];
|
||||
|
||||
if ($type === CatalogItemType::Bundle) {
|
||||
$this->createBundleComponents($catalogItem, $components);
|
||||
}
|
||||
|
||||
$createdVariants = [];
|
||||
foreach ($variants as $index => $variantData) {
|
||||
$createdVariants[] = [
|
||||
'variant' => $this->createVariant(
|
||||
$catalogItem,
|
||||
$variantData,
|
||||
$itemAttributes,
|
||||
$index,
|
||||
),
|
||||
'images' => $variantData['images'] ?? [],
|
||||
'index' => $index,
|
||||
];
|
||||
}
|
||||
|
||||
$this->attachImages($catalogItem, $images, 'images');
|
||||
|
||||
foreach ($createdVariants as $createdVariant) {
|
||||
$this->attachImages(
|
||||
$createdVariant['variant'],
|
||||
$createdVariant['images'],
|
||||
"variants.{$createdVariant['index']}.images",
|
||||
);
|
||||
}
|
||||
|
||||
return $catalogItem->load([
|
||||
'attachments',
|
||||
'inventory',
|
||||
'category',
|
||||
'brand',
|
||||
'itemAttributes.attribute',
|
||||
'variants.inventory',
|
||||
'variants.attachments',
|
||||
'variants.eventDate',
|
||||
'variants.eventDates',
|
||||
'variants.definitions.itemAttribute.attribute.options',
|
||||
'bundleComponents.catalogItem',
|
||||
'bundleComponents.variant.catalogItem',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function getDetail(CatalogItem $catalogItem, ?int $variantId = null): CatalogItem
|
||||
{
|
||||
$catalogItem->load([
|
||||
'attachments',
|
||||
'inventory',
|
||||
'category',
|
||||
'brand',
|
||||
'itemAttributes.attribute.options.validityTime',
|
||||
'itemAttributes.attribute.eventDates.validityTime',
|
||||
'variants' => fn ($query) => $query->orderBy('id'),
|
||||
'variants.inventory',
|
||||
'variants.attachments',
|
||||
'variants.eventDate',
|
||||
'variants.eventDates',
|
||||
'variants.definitions' => fn ($query) => $query->orderBy('id'),
|
||||
'variants.definitions.itemAttribute.attribute.options',
|
||||
'bundleComponents.catalogItem.inventory',
|
||||
'bundleComponents.variant.inventory',
|
||||
'bundleComponents.variant.definitions.itemAttribute.attribute',
|
||||
]);
|
||||
|
||||
$visibleVariants = $catalogItem->visibleVariants();
|
||||
if ($catalogItem->type === CatalogItemType::Standard
|
||||
&& ($catalogItem->inventory_id !== null || $catalogItem->variants->isNotEmpty())
|
||||
&& (($catalogItem->variants->isNotEmpty() && $visibleVariants->isEmpty())
|
||||
|| ! $catalogItem->isAvailable())) {
|
||||
throw new NotFoundHttpException('Catalog item is out of stock.');
|
||||
}
|
||||
|
||||
$catalogItem->setRelation('variants', $visibleVariants);
|
||||
$selectedVariant = $variantId === null
|
||||
? $visibleVariants->first()
|
||||
: $visibleVariants->firstWhere('id', $variantId);
|
||||
|
||||
if ($variantId !== null && $selectedVariant === null) {
|
||||
throw new NotFoundHttpException('Variant not found for catalog item.');
|
||||
}
|
||||
|
||||
$catalogItem->setRelation('selectedVariant', $selectedVariant);
|
||||
|
||||
return $catalogItem;
|
||||
}
|
||||
|
||||
/** @return LengthAwarePaginator<CatalogItem> */
|
||||
public function search(
|
||||
Tenant $tenant,
|
||||
string $term,
|
||||
int $perPage,
|
||||
int $page,
|
||||
): LengthAwarePaginator {
|
||||
$normalizedTerm = mb_strtolower($term);
|
||||
$containsPattern = "%{$normalizedTerm}%";
|
||||
$startsWithPattern = "{$normalizedTerm}%";
|
||||
|
||||
$paginator = CatalogItem::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->whereAvailable()
|
||||
->where(function (Builder $query) use ($containsPattern): void {
|
||||
$query
|
||||
->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern])
|
||||
->orWhereRaw('LOWER(descripcion) LIKE ?', [$containsPattern])
|
||||
->orWhereHas(
|
||||
'brand',
|
||||
fn (Builder $brandQuery) => $brandQuery
|
||||
->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern])
|
||||
)
|
||||
->orWhereHas(
|
||||
'category',
|
||||
fn (Builder $categoryQuery) => $categoryQuery
|
||||
->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern])
|
||||
);
|
||||
})
|
||||
->with([
|
||||
'attachments',
|
||||
'inventory',
|
||||
'itemAttributes.attribute',
|
||||
'variants.inventory',
|
||||
'variants.attachments',
|
||||
'variants.eventDate',
|
||||
'variants.eventDates',
|
||||
'variants.definitions.itemAttribute.attribute.options',
|
||||
'bundleComponents.catalogItem',
|
||||
'bundleComponents.variant.catalogItem',
|
||||
])
|
||||
->orderByRaw(
|
||||
'CASE WHEN LOWER(nombre) = ? THEN 0 WHEN LOWER(nombre) LIKE ? THEN 1 ELSE 2 END',
|
||||
[$normalizedTerm, $startsWithPattern],
|
||||
)
|
||||
->orderBy('nombre')
|
||||
->paginate(perPage: $perPage, pageName: 'page', page: $page);
|
||||
|
||||
return $paginator->withPath(route('catalog-items.index', [
|
||||
'tenant' => $tenant->codigo,
|
||||
]));
|
||||
}
|
||||
|
||||
/** @return LengthAwarePaginator<CatalogItem> */
|
||||
public function categoryItems(
|
||||
Tenant $tenant,
|
||||
Category $category,
|
||||
int $perPage,
|
||||
int $page,
|
||||
): LengthAwarePaginator {
|
||||
return CatalogItem::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->where('category_id', $category->id)
|
||||
->whereAvailable()
|
||||
->with([
|
||||
'attachments',
|
||||
'inventory',
|
||||
'itemAttributes.attribute',
|
||||
'variants.inventory',
|
||||
'variants.attachments',
|
||||
'variants.eventDate',
|
||||
'variants.eventDates',
|
||||
'variants.definitions.itemAttribute.attribute.options',
|
||||
'bundleComponents.catalogItem',
|
||||
'bundleComponents.variant.catalogItem',
|
||||
])
|
||||
->orderBy('nombre')
|
||||
->paginate(perPage: $perPage, pageName: 'page', page: $page);
|
||||
}
|
||||
|
||||
public function delete(CatalogItem $catalogItem): void
|
||||
{
|
||||
DB::transaction(function () use ($catalogItem): void {
|
||||
$catalogItem->variants()->delete();
|
||||
$catalogItem->delete();
|
||||
});
|
||||
}
|
||||
|
||||
public function deleteVariant(Variant $variant): void
|
||||
{
|
||||
DB::transaction(function () use ($variant): void {
|
||||
$variant = Variant::query()
|
||||
->lockForUpdate()
|
||||
->findOrFail($variant->getKey());
|
||||
$catalogItem = CatalogItem::query()
|
||||
->lockForUpdate()
|
||||
->findOrFail($variant->catalog_item_id);
|
||||
$variant->delete();
|
||||
|
||||
$sellableVariants = $catalogItem->variants()
|
||||
->whereNull('sales_disabled_at')
|
||||
->whereNull('replaced_by_variant_id');
|
||||
|
||||
if (! (clone $sellableVariants)->exists()) {
|
||||
$this->delete($catalogItem);
|
||||
} elseif (($minimumPrice = (clone $sellableVariants)->min('precio')) !== null) {
|
||||
$catalogItem->update(['precio' => $minimumPrice]);
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
private function createInventory(int $realStock): Inventory
|
||||
{
|
||||
return Inventory::query()->create([
|
||||
'real_stock' => $realStock,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $components
|
||||
*/
|
||||
private function createBundleComponents(CatalogItem $bundle, array $components): void
|
||||
{
|
||||
$seen = [];
|
||||
|
||||
foreach (array_values($components) as $index => $componentData) {
|
||||
$catalogItemId = (int) $componentData['catalog_item_id'];
|
||||
$variantId = isset($componentData['variant_id'])
|
||||
? (int) $componentData['variant_id']
|
||||
: null;
|
||||
$key = $catalogItemId.':'.($variantId ?? 'direct');
|
||||
|
||||
if (isset($seen[$key])) {
|
||||
throw ValidationException::withMessages([
|
||||
"components.{$index}" => [__('api.catalog.duplicate_component')],
|
||||
]);
|
||||
}
|
||||
$seen[$key] = true;
|
||||
|
||||
$componentItem = CatalogItem::query()
|
||||
->whereKey($catalogItemId)
|
||||
->where('tenant_code', $bundle->tenant_code)
|
||||
->first();
|
||||
|
||||
if ($componentItem === null) {
|
||||
throw ValidationException::withMessages([
|
||||
"components.{$index}.catalog_item_id" => [
|
||||
__('api.catalog.component_wrong_tenant'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($componentItem->is($bundle) || $componentItem->type !== CatalogItemType::Standard) {
|
||||
throw ValidationException::withMessages([
|
||||
"components.{$index}.catalog_item_id" => [
|
||||
__('api.catalog.invalid_component'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$hasVariants = $componentItem->variants()->exists();
|
||||
if ($hasVariants && $variantId === null) {
|
||||
throw ValidationException::withMessages([
|
||||
"components.{$index}.variant_id" => [
|
||||
__('api.catalog.component_variant_required'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $hasVariants && $variantId !== null) {
|
||||
throw ValidationException::withMessages([
|
||||
"components.{$index}.variant_id" => [
|
||||
__('api.catalog.component_variant_forbidden'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($variantId !== null && ! $componentItem->variants()
|
||||
->whereKey($variantId)
|
||||
->whereNull('sales_disabled_at')
|
||||
->whereNull('replaced_by_variant_id')
|
||||
->exists()) {
|
||||
throw ValidationException::withMessages([
|
||||
"components.{$index}.variant_id" => [
|
||||
__('api.catalog.component_variant_invalid'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$bundle->bundleComponents()->create([
|
||||
'component_catalog_item_id' => $componentItem->id,
|
||||
'component_variant_id' => $variantId,
|
||||
'quantity' => (int) $componentData['quantity'],
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @param array<int, array<string, mixed>> $components
|
||||
*/
|
||||
private function validateBundleData(array $data, array $components): void
|
||||
{
|
||||
if ($components === []) {
|
||||
throw ValidationException::withMessages([
|
||||
'components' => [__('api.catalog.bundle_component_required')],
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'real_stock',
|
||||
'inventory_policy',
|
||||
'attribute_codes',
|
||||
'variants',
|
||||
'has_tickets',
|
||||
] as $field) {
|
||||
if (array_key_exists($field, $data)) {
|
||||
throw ValidationException::withMessages([
|
||||
$field => [__('api.catalog.bundle_field_forbidden', ['field' => $field])],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $images
|
||||
*/
|
||||
private function attachImages(
|
||||
CatalogItem|Variant $owner,
|
||||
array $images,
|
||||
string $validationKey,
|
||||
): void {
|
||||
foreach (array_values($images) as $order => $image) {
|
||||
$attachment = $this->resolveAttachment($image, "{$validationKey}.{$order}");
|
||||
|
||||
$owner->attachments()->attach($attachment->id, ['orden' => $order]);
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveAttachment(mixed $image, string $validationKey): Attachment
|
||||
{
|
||||
if (is_string($image) && Str::isUuid($image)) {
|
||||
$attachment = Attachment::query()->where('key', $image)->first();
|
||||
|
||||
if ($attachment === null) {
|
||||
throw ValidationException::withMessages([
|
||||
$validationKey => [__('api.catalog.attachment_not_found')],
|
||||
]);
|
||||
}
|
||||
|
||||
return $attachment;
|
||||
}
|
||||
|
||||
return $this->attachmentService->store($image, 'catalog-items');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $attributeCodes
|
||||
* @param array<int, string> $multiSelectAttributeCodes
|
||||
* @param array<int, string> $hiddenAttributeCodes
|
||||
* @param array<string, string> $ticketAttributeLabels
|
||||
* @return array<string, ItemAttribute>
|
||||
*/
|
||||
private function createItemAttributes(
|
||||
CatalogItem $catalogItem,
|
||||
array $attributeCodes,
|
||||
array $multiSelectAttributeCodes = [],
|
||||
array $hiddenAttributeCodes = [],
|
||||
array $ticketAttributeLabels = [],
|
||||
): array {
|
||||
$itemAttributes = [];
|
||||
$attributeCodes = array_values(array_unique($attributeCodes));
|
||||
$attributes = Attribute::query()
|
||||
->where('tenant_codigo', $catalogItem->tenant_code)
|
||||
->whereIn('codigo', $attributeCodes)
|
||||
->get()
|
||||
->keyBy('codigo');
|
||||
|
||||
foreach ($attributeCodes as $attributeCode) {
|
||||
$attribute = $attributes->get($attributeCode);
|
||||
|
||||
if ($attribute === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'attribute_codes' => [
|
||||
__('api.catalog.attribute_not_found', ['attribute' => $attributeCode]),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$itemAttribute = $catalogItem->itemAttributes()->create([
|
||||
'attribute_id' => $attribute->id,
|
||||
'allow_multi_select' => in_array($attributeCode, $multiSelectAttributeCodes, true),
|
||||
'show_in_selector' => ! in_array($attributeCode, $hiddenAttributeCodes, true),
|
||||
'ticket_label' => $ticketAttributeLabels[$attributeCode] ?? null,
|
||||
]);
|
||||
|
||||
$itemAttributes[$attributeCode] = $itemAttribute;
|
||||
}
|
||||
|
||||
return $itemAttributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @param array<string, ItemAttribute> $itemAttributes
|
||||
*/
|
||||
private function createVariant(
|
||||
CatalogItem $catalogItem,
|
||||
array $data,
|
||||
array $itemAttributes,
|
||||
int $index,
|
||||
): Variant {
|
||||
unset($data['images']);
|
||||
|
||||
if (
|
||||
array_key_exists('inventory_id', $data)
|
||||
|| array_key_exists('reserved_stock', $data)
|
||||
|| array_key_exists('sold_units', $data)
|
||||
) {
|
||||
throw ValidationException::withMessages([
|
||||
"variants.{$index}.inventory" => [
|
||||
__('api.catalog.managed_inventory_fields'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$inventory = $this->createInventory((int) ($data['real_stock'] ?? 0));
|
||||
$eventDateIds = collect($data['event_date_ids'] ?? [])
|
||||
->when(
|
||||
isset($data['event_date_id']),
|
||||
fn ($ids) => $ids->push($data['event_date_id']),
|
||||
)
|
||||
->filter(fn ($id): bool => $id !== null)
|
||||
->map(fn ($id): int => (int) $id)
|
||||
->unique()
|
||||
->sort()
|
||||
->values();
|
||||
|
||||
$eventDateItemAttribute = $itemAttributes['event_date'] ?? null;
|
||||
if ($eventDateItemAttribute !== null && (
|
||||
$eventDateIds->isEmpty()
|
||||
|| (! $eventDateItemAttribute->allow_multi_select && $eventDateIds->count() !== 1)
|
||||
)) {
|
||||
throw ValidationException::withMessages([
|
||||
"variants.{$index}.event_date_ids" => [
|
||||
$eventDateItemAttribute->allow_multi_select
|
||||
? __('api.catalog.event_date_selection_required')
|
||||
: __('api.catalog.single_event_date_required'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$validEventDateCount = EventDate::query()
|
||||
->whereKey($eventDateIds)
|
||||
->where('tenant_code', $catalogItem->tenant_code)
|
||||
->count();
|
||||
|
||||
if ($validEventDateCount !== $eventDateIds->count()) {
|
||||
throw ValidationException::withMessages([
|
||||
"variants.{$index}.event_date_ids" => [
|
||||
__('api.catalog.event_date_wrong_tenant'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$variant = $catalogItem->variants()->create([
|
||||
'inventory_id' => $inventory->id,
|
||||
'event_date_id' => $eventDateIds->count() === 1 ? $eventDateIds->first() : null,
|
||||
'descripcion' => $data['descripcion'] ?? null,
|
||||
'precio' => $data['precio'] ?? null,
|
||||
]);
|
||||
$variant->eventDates()->sync($eventDateIds->all());
|
||||
$variant->setRelation('catalogItem', $catalogItem);
|
||||
|
||||
foreach ($data['values'] ?? [] as $attributeCode => $value) {
|
||||
$itemAttribute = $itemAttributes[$attributeCode] ?? null;
|
||||
|
||||
if ($itemAttribute === null) {
|
||||
throw ValidationException::withMessages([
|
||||
"variants.{$index}.values.{$attributeCode}" => [
|
||||
__('api.catalog.attribute_not_on_item'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ($this->validatedVariantValues(
|
||||
$itemAttribute,
|
||||
$value,
|
||||
"variants.{$index}.values.{$attributeCode}",
|
||||
) as $validatedValue) {
|
||||
$variant->definitions()->create([
|
||||
'item_attribute_id' => $itemAttribute->id,
|
||||
'value' => $validatedValue,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $variant;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $variants
|
||||
* @param array<int, string> $attributeCodes
|
||||
*/
|
||||
private function validateUniqueVariantCombinations(array $variants, array $attributeCodes): void
|
||||
{
|
||||
$seen = [];
|
||||
$attributeCodes = array_values(array_unique($attributeCodes));
|
||||
sort($attributeCodes);
|
||||
|
||||
foreach (array_values($variants) as $index => $variant) {
|
||||
$eventDateIds = collect($variant['event_date_ids'] ?? [])
|
||||
->when(
|
||||
isset($variant['event_date_id']),
|
||||
fn ($ids) => $ids->push($variant['event_date_id']),
|
||||
)
|
||||
->map(fn ($id): int => (int) $id)
|
||||
->unique()
|
||||
->sort()
|
||||
->values()
|
||||
->implode(',');
|
||||
$combination = [$eventDateIds];
|
||||
|
||||
foreach ($attributeCodes as $attributeCode) {
|
||||
$values = $variant['values'][$attributeCode] ?? '';
|
||||
$normalizedValues = collect(is_array($values) ? $values : [$values])
|
||||
->map(fn ($value): string => $this->normalizeVariantValue((string) $value))
|
||||
->unique()
|
||||
->sort()
|
||||
->values()
|
||||
->implode(',');
|
||||
$combination[] = $normalizedValues;
|
||||
}
|
||||
|
||||
$key = implode('|', $combination);
|
||||
if (isset($seen[$key])) {
|
||||
throw ValidationException::withMessages([
|
||||
"variants.{$index}" => [__('api.catalog.duplicate_variant_combination')],
|
||||
]);
|
||||
}
|
||||
|
||||
$seen[$key] = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<int, string> */
|
||||
private function validatedVariantValues(
|
||||
ItemAttribute $itemAttribute,
|
||||
mixed $value,
|
||||
string $validationKey,
|
||||
): array {
|
||||
$values = is_array($value) ? array_values($value) : [$value];
|
||||
|
||||
if ($values === [] || (! $itemAttribute->allow_multi_select && count($values) !== 1)) {
|
||||
throw ValidationException::withMessages([
|
||||
$validationKey => [
|
||||
$itemAttribute->allow_multi_select
|
||||
? __('api.catalog.multi_value_required')
|
||||
: __('api.catalog.single_value_required'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
if (collect($values)->contains(fn ($item): bool => ! is_string($item) || trim($item) === '')) {
|
||||
throw ValidationException::withMessages([
|
||||
$validationKey => [__('api.catalog.selected_values_non_empty')],
|
||||
]);
|
||||
}
|
||||
|
||||
$normalizedValues = collect($values)
|
||||
->map(fn (string $item): string => $this->normalizeVariantValue($item));
|
||||
|
||||
if ($normalizedValues->unique()->count() !== count($values)) {
|
||||
throw ValidationException::withMessages([
|
||||
$validationKey => [__('api.catalog.selected_values_distinct')],
|
||||
]);
|
||||
}
|
||||
|
||||
$attribute = $itemAttribute->attribute;
|
||||
if ($attribute->type->supportsOptions() && ! $attribute->type->usesDynamicOptions()) {
|
||||
$optionsByNormalizedValue = $attribute->options
|
||||
->keyBy(fn ($option): string => $this->normalizeVariantValue($option->value));
|
||||
|
||||
$resolvedOptions = $normalizedValues->map(fn (string $normalizedValue) => $optionsByNormalizedValue->get($normalizedValue));
|
||||
if ($resolvedOptions->contains(null)) {
|
||||
throw ValidationException::withMessages([
|
||||
$validationKey => [__('api.catalog.invalid_attribute_options')],
|
||||
]);
|
||||
}
|
||||
|
||||
return $resolvedOptions->pluck('value')->all();
|
||||
}
|
||||
|
||||
return collect($values)->map(fn (string $item): string => trim($item))->all();
|
||||
}
|
||||
|
||||
private function normalizeVariantValue(string $value): string
|
||||
{
|
||||
return Str::ascii(mb_strtolower(trim($value)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @param array<int, array<string, mixed>> $variants
|
||||
*/
|
||||
private function validateInventoryStrategy(
|
||||
array $data,
|
||||
array $variants,
|
||||
bool $hasVariants,
|
||||
bool $hasDirectStock,
|
||||
): void {
|
||||
if ($hasVariants && $variants === []) {
|
||||
throw ValidationException::withMessages([
|
||||
'variants' => [
|
||||
__('api.catalog.variants_required'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $hasVariants && $variants !== []) {
|
||||
throw ValidationException::withMessages([
|
||||
'variants' => [
|
||||
__('api.catalog.variants_forbidden'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($hasVariants && $hasDirectStock) {
|
||||
throw ValidationException::withMessages([
|
||||
'real_stock' => [
|
||||
__('api.catalog.direct_inventory_forbidden'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
if (
|
||||
array_key_exists('inventory_id', $data)
|
||||
|| array_key_exists('reserved_stock', $data)
|
||||
|| array_key_exists('sold_units', $data)
|
||||
) {
|
||||
throw ValidationException::withMessages([
|
||||
'inventory' => [
|
||||
__('api.catalog.managed_inventory_fields'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Services;
|
||||
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Catalog\Models\StockReservation;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class ExpireStockReservationsService
|
||||
{
|
||||
private const BATCH_SIZE = 500;
|
||||
|
||||
public function __construct(
|
||||
private readonly ReleaseCheckoutService $purchases,
|
||||
private readonly StockReservationService $reservations,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{purchases: int, cart_reservations: int, orphan_reservations: int, failed: int}
|
||||
*/
|
||||
public function expireOverdue(): array
|
||||
{
|
||||
$summary = [
|
||||
'purchases' => 0,
|
||||
'cart_reservations' => 0,
|
||||
'orphan_reservations' => 0,
|
||||
'failed' => 0,
|
||||
];
|
||||
$lastReservationId = 0;
|
||||
|
||||
do {
|
||||
$reservationIds = StockReservation::query()
|
||||
->where('status', StockReservation::STATUS_ACTIVE)
|
||||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '<=', now())
|
||||
->where('id', '>', $lastReservationId)
|
||||
->orderBy('id')
|
||||
->limit(self::BATCH_SIZE)
|
||||
->pluck('id');
|
||||
|
||||
foreach ($reservationIds as $reservationId) {
|
||||
$lastReservationId = (int) $reservationId;
|
||||
|
||||
try {
|
||||
$owner = $this->expireReservation($lastReservationId);
|
||||
if ($owner !== null) {
|
||||
$summary[$owner]++;
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
$summary['failed']++;
|
||||
Log::channel('commands')->error('Failed to expire overdue stock reservation.', [
|
||||
'command' => 'reservations:expire',
|
||||
'stock_reservation_id' => $lastReservationId,
|
||||
'exception' => $exception,
|
||||
]);
|
||||
}
|
||||
}
|
||||
} while ($reservationIds->count() === self::BATCH_SIZE);
|
||||
|
||||
Log::channel('commands')->info('Stock reservation cleanup completed.', [
|
||||
'command' => 'reservations:expire',
|
||||
'expired_purchases' => $summary['purchases'],
|
||||
'expired_cart_reservations' => $summary['cart_reservations'],
|
||||
'expired_orphan_reservations' => $summary['orphan_reservations'],
|
||||
'failed_reservations' => $summary['failed'],
|
||||
'total_expired' => $summary['purchases']
|
||||
+ $summary['cart_reservations']
|
||||
+ $summary['orphan_reservations'],
|
||||
]);
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
public function expireIfOverdue(int $reservationId): bool
|
||||
{
|
||||
/** @var StockReservation|null $reservation */
|
||||
$reservation = StockReservation::query()->find($reservationId);
|
||||
if ($reservation?->status === StockReservation::STATUS_EXPIRED) {
|
||||
return true;
|
||||
}
|
||||
if (! $this->isOverdue($reservation)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->expireReservation($reservationId) !== null;
|
||||
}
|
||||
|
||||
/** @return 'purchases'|'cart_reservations'|'orphan_reservations'|null */
|
||||
private function expireReservation(int $reservationId): ?string
|
||||
{
|
||||
$purchaseId = Purchase::query()
|
||||
->where('stock_reservation_id', $reservationId)
|
||||
->value('id');
|
||||
if ($purchaseId !== null) {
|
||||
return $this->expirePurchase((int) $purchaseId);
|
||||
}
|
||||
|
||||
$cartId = Cart::query()
|
||||
->where('current_stock_reservation_id', $reservationId)
|
||||
->where('status', 'active')
|
||||
->value('id');
|
||||
if ($cartId !== null) {
|
||||
return $this->expireCart((int) $cartId, $reservationId);
|
||||
}
|
||||
|
||||
return $this->expireOrphan($reservationId);
|
||||
}
|
||||
|
||||
/** @return 'purchases'|null */
|
||||
private function expirePurchase(int $purchaseId): ?string
|
||||
{
|
||||
/** @var Purchase|null $purchase */
|
||||
$purchase = Purchase::query()->find($purchaseId);
|
||||
if ($purchase === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$purchase = $this->purchases->expire($purchase);
|
||||
if ($purchase->status === Purchase::STATUS_EXPIRED) {
|
||||
return 'purchases';
|
||||
}
|
||||
|
||||
$reservation = $purchase->stockReservation;
|
||||
if ($this->isOverdue($reservation)) {
|
||||
throw new RuntimeException('An overdue active reservation belongs to a purchase that cannot expire.');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @return 'cart_reservations'|null */
|
||||
private function expireCart(int $cartId, int $reservationId): ?string
|
||||
{
|
||||
return DB::transaction(function () use ($cartId, $reservationId): ?string {
|
||||
/** @var Cart|null $cart */
|
||||
$cart = Cart::query()
|
||||
->whereKey($cartId)
|
||||
->where('current_stock_reservation_id', $reservationId)
|
||||
->where('status', Cart::STATUS_ACTIVE)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
if ($cart === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var StockReservation|null $reservation */
|
||||
$reservation = StockReservation::query()->lockForUpdate()->find($reservationId);
|
||||
if (! $this->isOverdue($reservation)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->reservations->expire($reservation);
|
||||
$cart->update(['status' => Cart::STATUS_EXPIRED]);
|
||||
|
||||
return 'cart_reservations';
|
||||
});
|
||||
}
|
||||
|
||||
/** @return 'orphan_reservations'|null */
|
||||
private function expireOrphan(int $reservationId): ?string
|
||||
{
|
||||
/** @var StockReservation|null $reservation */
|
||||
$reservation = StockReservation::query()->find($reservationId);
|
||||
if (! $this->isOverdue($reservation)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->reservations->expire($reservation);
|
||||
|
||||
return 'orphan_reservations';
|
||||
}
|
||||
|
||||
private function isOverdue(?StockReservation $reservation): bool
|
||||
{
|
||||
return $reservation !== null
|
||||
&& $reservation->status === StockReservation::STATUS_ACTIVE
|
||||
&& $reservation->expires_at !== null
|
||||
&& ! $reservation->expires_at->isFuture();
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Services;
|
||||
|
||||
use App\Domains\Catalog\Enums\FeaturedGroupSource;
|
||||
use App\Domains\Catalog\Enums\GroupLayout;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||
use App\Domains\Catalog\Resources\CatalogFeaturedItemResource;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
class FeaturedGroupService
|
||||
{
|
||||
private const ITEMS_PER_PAGE = 12;
|
||||
|
||||
/** @return array<array-key, mixed> */
|
||||
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);
|
||||
|
||||
if ($featuredGroup->group_layout === GroupLayout::Single) {
|
||||
$query->limit(1);
|
||||
}
|
||||
|
||||
$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()
|
||||
->getData(true);
|
||||
}
|
||||
|
||||
/** @return Builder<CatalogItem> */
|
||||
private function itemsQuery(FeaturedGroup $featuredGroup): Builder
|
||||
{
|
||||
$query = CatalogItem::query()
|
||||
->where('catalog_items.tenant_code', $featuredGroup->tenant_code)
|
||||
->whereAvailable()
|
||||
->where(function (Builder $query): void {
|
||||
$query
|
||||
->whereDoesntHave('category')
|
||||
->orWhereHas(
|
||||
'category',
|
||||
fn (Builder $categoryQuery): Builder => $categoryQuery
|
||||
->where('is_enabled', true),
|
||||
);
|
||||
})
|
||||
->with([
|
||||
'inventory',
|
||||
'attachments',
|
||||
'itemAttributes.attribute',
|
||||
'variants.inventory',
|
||||
'variants.attachments',
|
||||
'variants.eventDate',
|
||||
'variants.eventDates',
|
||||
'variants.definitions.itemAttribute.attribute.options',
|
||||
'bundleComponents.catalogItem',
|
||||
'bundleComponents.variant.catalogItem',
|
||||
]);
|
||||
|
||||
return match ($featuredGroup->source_type) {
|
||||
FeaturedGroupSource::Manual => $query
|
||||
->select('catalog_items.*')
|
||||
->join('featured_items', 'featured_items.catalog_item_id', '=', 'catalog_items.id')
|
||||
->where('featured_items.featured_group_id', $featuredGroup->id)
|
||||
->orderBy('featured_items.order')
|
||||
->orderBy('featured_items.id'),
|
||||
FeaturedGroupSource::Category => $query
|
||||
->where('catalog_items.category_id', $featuredGroup->category_id)
|
||||
->orderBy('catalog_items.id'),
|
||||
FeaturedGroupSource::All => $query
|
||||
->orderBy('catalog_items.group_order')
|
||||
->orderBy('catalog_items.id'),
|
||||
};
|
||||
}
|
||||
|
||||
private function paginateItems(
|
||||
FeaturedGroup $featuredGroup,
|
||||
int $page,
|
||||
): LengthAwarePaginator {
|
||||
$paginator = $this->itemsQuery($featuredGroup)->paginate(
|
||||
perPage: self::ITEMS_PER_PAGE,
|
||||
pageName: 'page',
|
||||
page: $page,
|
||||
);
|
||||
|
||||
return $paginator->withPath(route('catalog.featured-groups.items.index', [
|
||||
'tenant' => $featuredGroup->tenant_code,
|
||||
'featuredGroup' => $featuredGroup->id,
|
||||
]));
|
||||
}
|
||||
|
||||
private function attachGroup(iterable $items, FeaturedGroup $featuredGroup): void
|
||||
{
|
||||
foreach ($items as $item) {
|
||||
$item->setRelation('featuredGroup', $featuredGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Services;
|
||||
|
||||
use App\Domains\Catalog\Enums\FeaturedGroupSource;
|
||||
use App\Domains\Catalog\Enums\GroupLayout;
|
||||
use App\Domains\Catalog\Enums\ProductLayout;
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class OnTicketFeaturedGroupService
|
||||
{
|
||||
/** @return Collection<int, FeaturedGroup> */
|
||||
public function forTenant(Tenant $tenant): Collection
|
||||
{
|
||||
return FeaturedGroup::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->where('source_type', FeaturedGroupSource::Category)
|
||||
->whereHas('category', fn ($query) => $query->where('tenant_code', $tenant->codigo))
|
||||
->with('category')
|
||||
->orderBy('group_order')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
}
|
||||
|
||||
/** @param array{category_name: string, is_featured: bool} $data */
|
||||
public function create(Tenant $tenant, array $data): FeaturedGroup
|
||||
{
|
||||
return DB::transaction(function () use ($tenant, $data): FeaturedGroup {
|
||||
$category = $tenant->categories()->create([
|
||||
'nombre' => $data['category_name'],
|
||||
]);
|
||||
|
||||
$featuredGroup = FeaturedGroup::query()->create([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'source_type' => FeaturedGroupSource::Category,
|
||||
'category_id' => $category->id,
|
||||
'product_layout' => $this->productLayout($data['is_featured']),
|
||||
'group_layout' => GroupLayout::Paginated,
|
||||
'group_name' => $data['category_name'],
|
||||
'group_order' => $this->nextOrder($tenant),
|
||||
]);
|
||||
|
||||
return $featuredGroup->setRelation('category', $category);
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array{category_name: string, is_featured: bool} $data */
|
||||
public function update(
|
||||
Tenant $tenant,
|
||||
FeaturedGroup $featuredGroup,
|
||||
array $data,
|
||||
): FeaturedGroup {
|
||||
return DB::transaction(function () use ($tenant, $featuredGroup, $data): FeaturedGroup {
|
||||
$featuredGroup = FeaturedGroup::query()
|
||||
->whereKey($featuredGroup->getKey())
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->where('source_type', FeaturedGroupSource::Category)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
$category = Category::query()
|
||||
->whereKey($featuredGroup->category_id)
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
$category->update(['nombre' => $data['category_name']]);
|
||||
$featuredGroup->update([
|
||||
'group_name' => $data['category_name'],
|
||||
'product_layout' => $this->productLayout($data['is_featured']),
|
||||
'group_layout' => GroupLayout::Paginated,
|
||||
]);
|
||||
|
||||
return $featuredGroup->setRelation('category', $category);
|
||||
});
|
||||
}
|
||||
|
||||
private function productLayout(bool $isFeatured): ProductLayout
|
||||
{
|
||||
return $isFeatured ? ProductLayout::Row : ProductLayout::ColumnWithCart;
|
||||
}
|
||||
|
||||
private function nextOrder(Tenant $tenant): int
|
||||
{
|
||||
$maximumOrder = FeaturedGroup::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->max('group_order');
|
||||
|
||||
return $maximumOrder === null ? 0 : ((int) $maximumOrder) + 1;
|
||||
}
|
||||
}
|
||||
@@ -1,497 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Services;
|
||||
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Cart\Models\CartItem;
|
||||
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
|
||||
use App\Domains\Catalog\Models\Inventory;
|
||||
use App\Domains\Catalog\Models\StockReservation;
|
||||
use App\Domains\Catalog\Models\StockReservationLine;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class StockReservationService
|
||||
{
|
||||
public const REASON_CART_EMPTY = 'cart_empty';
|
||||
|
||||
public const REASON_CART_CHANGED = 'cart_changed';
|
||||
|
||||
public const REASON_EVENT_DATE_RESCHEDULED = 'event_date_rescheduled';
|
||||
|
||||
public const REASON_EVENT_DATE_SUSPENDED = 'event_date_suspended';
|
||||
|
||||
public const REASON_PURCHASE_SUPERSEDED = 'purchase_superseded';
|
||||
|
||||
public const REASON_PURCHASE_CANCELLED = 'purchase_cancelled';
|
||||
|
||||
public const REASON_PAYMENT_REJECTED = 'payment_rejected';
|
||||
|
||||
public const REASON_MANUAL_RELEASE = 'manual_release';
|
||||
|
||||
public function __construct(
|
||||
private readonly CatalogInventoryService $inventory,
|
||||
) {}
|
||||
|
||||
public function syncCart(Cart $cart): ?StockReservation
|
||||
{
|
||||
return DB::transaction(function () use ($cart): ?StockReservation {
|
||||
/** @var Cart $lockedCart */
|
||||
$lockedCart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
|
||||
$items = $lockedCart->items()->orderBy('id')->lockForUpdate()->get();
|
||||
$this->loadSelections($items);
|
||||
$requirements = $this->requirementsForItems($items);
|
||||
|
||||
$reservation = $lockedCart->current_stock_reservation_id === null
|
||||
? null
|
||||
: StockReservation::query()->lockForUpdate()->find($lockedCart->current_stock_reservation_id);
|
||||
|
||||
if ($reservation !== null) {
|
||||
$this->assertUsableCartReservation($reservation);
|
||||
}
|
||||
|
||||
if ($requirements === []) {
|
||||
if ($reservation !== null && $reservation->status === StockReservation::STATUS_ACTIVE) {
|
||||
$this->finalizeLocked(
|
||||
$reservation,
|
||||
StockReservation::STATUS_RELEASED,
|
||||
self::REASON_CART_EMPTY,
|
||||
);
|
||||
}
|
||||
|
||||
$lockedCart->update(['current_stock_reservation_id' => null]);
|
||||
$cart->current_stock_reservation_id = null;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($reservation === null) {
|
||||
$reservation = StockReservation::query()->create([
|
||||
'status' => StockReservation::STATUS_ACTIVE,
|
||||
'expires_at' => $this->expiration(),
|
||||
]);
|
||||
$lockedCart->update(['current_stock_reservation_id' => $reservation->getKey()]);
|
||||
}
|
||||
|
||||
if (Purchase::query()->where('stock_reservation_id', $reservation->getKey())->exists()) {
|
||||
throw new \InvalidArgumentException('La reserva vinculada a una compra no se puede modificar.');
|
||||
}
|
||||
|
||||
$currentLines = StockReservationLine::query()
|
||||
->where('stock_reservation_id', $reservation->getKey())
|
||||
->orderBy('inventory_id')
|
||||
->lockForUpdate()
|
||||
->get()
|
||||
->keyBy('inventory_id');
|
||||
$inventoryIds = collect(array_keys($requirements))
|
||||
->merge($currentLines->keys())
|
||||
->map(fn ($id): int => (int) $id)
|
||||
->unique()
|
||||
->sort()
|
||||
->values();
|
||||
$inventories = Inventory::query()
|
||||
->whereKey($inventoryIds)
|
||||
->orderBy('id')
|
||||
->lockForUpdate()
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
foreach ($inventoryIds as $inventoryId) {
|
||||
$inventory = $inventories->get($inventoryId)
|
||||
?? throw new \InvalidArgumentException('No se encontró el inventario requerido.');
|
||||
$previous = (int) ($currentLines->get($inventoryId)?->quantity ?? 0);
|
||||
$required = (int) ($requirements[$inventoryId]['quantity'] ?? 0);
|
||||
$delta = $required - $previous;
|
||||
|
||||
if ($delta > 0
|
||||
&& $requirements[$inventoryId]['tracks_inventory']
|
||||
&& $inventory->availableStock() < $delta) {
|
||||
throw new \InvalidArgumentException('No hay suficiente stock disponible para reservar el carrito.');
|
||||
}
|
||||
|
||||
if ($delta < 0 && $inventory->reserved_stock < abs($delta)) {
|
||||
throw new \InvalidArgumentException('La reserva de stock del carrito es inconsistente.');
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($inventoryIds as $inventoryId) {
|
||||
/** @var Inventory $inventory */
|
||||
$inventory = $inventories->get($inventoryId);
|
||||
$line = $currentLines->get($inventoryId);
|
||||
$previous = (int) ($line?->quantity ?? 0);
|
||||
$required = (int) ($requirements[$inventoryId]['quantity'] ?? 0);
|
||||
$delta = $required - $previous;
|
||||
|
||||
if ($delta > 0) {
|
||||
$inventory->reserve($delta, $requirements[$inventoryId]['tracks_inventory']);
|
||||
} elseif ($delta < 0) {
|
||||
$inventory->release(abs($delta));
|
||||
}
|
||||
|
||||
if ($required === 0) {
|
||||
$line?->delete();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
StockReservationLine::query()->updateOrCreate(
|
||||
[
|
||||
'stock_reservation_id' => $reservation->getKey(),
|
||||
'inventory_id' => $inventoryId,
|
||||
],
|
||||
[
|
||||
'quantity' => $required,
|
||||
'tracks_inventory' => $requirements[$inventoryId]['tracks_inventory'],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
$reservation->update([
|
||||
'expires_at' => $this->expiration(),
|
||||
'release_reason' => null,
|
||||
]);
|
||||
$cart->current_stock_reservation_id = $reservation->getKey();
|
||||
|
||||
return $reservation->fresh('lines');
|
||||
});
|
||||
}
|
||||
|
||||
public function attachToPurchase(
|
||||
Cart $cart,
|
||||
Purchase $purchase,
|
||||
Carbon $expiresAt,
|
||||
): StockReservation {
|
||||
return DB::transaction(function () use ($cart, $purchase, $expiresAt): StockReservation {
|
||||
/** @var Cart $lockedCart */
|
||||
$lockedCart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
|
||||
/** @var Purchase $lockedPurchase */
|
||||
$lockedPurchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
|
||||
|
||||
if ($lockedCart->current_stock_reservation_id === null) {
|
||||
throw new \InvalidArgumentException('El carrito no tiene una reserva de stock activa.');
|
||||
}
|
||||
|
||||
/** @var StockReservation $reservation */
|
||||
$reservation = StockReservation::query()->lockForUpdate()->findOrFail($lockedCart->current_stock_reservation_id);
|
||||
$this->assertUsableCartReservation($reservation);
|
||||
|
||||
$linkedPurchase = Purchase::query()
|
||||
->where('stock_reservation_id', $reservation->getKey())
|
||||
->whereKeyNot($lockedPurchase->getKey())
|
||||
->exists();
|
||||
if ($linkedPurchase) {
|
||||
throw new \InvalidArgumentException('La reserva de stock ya pertenece a otra compra.');
|
||||
}
|
||||
|
||||
$lockedPurchase->update(['stock_reservation_id' => $reservation->getKey()]);
|
||||
$reservation->update(['expires_at' => $expiresAt]);
|
||||
$purchase->stock_reservation_id = $reservation->getKey();
|
||||
$cart->current_stock_reservation_id = $reservation->getKey();
|
||||
|
||||
return $reservation->fresh('lines');
|
||||
});
|
||||
}
|
||||
|
||||
public function commit(Purchase $purchase): void
|
||||
{
|
||||
DB::transaction(function () use ($purchase): void {
|
||||
/** @var Purchase $purchase */
|
||||
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
|
||||
if ($purchase->stock_reservation_id === null) {
|
||||
throw new \InvalidArgumentException('La compra no tiene una reserva de stock.');
|
||||
}
|
||||
|
||||
/** @var StockReservation $reservation */
|
||||
$reservation = StockReservation::query()->lockForUpdate()->findOrFail($purchase->stock_reservation_id);
|
||||
if ($reservation->status === StockReservation::STATUS_COMMITTED) {
|
||||
return;
|
||||
}
|
||||
if ($reservation->status !== StockReservation::STATUS_ACTIVE) {
|
||||
throw new \InvalidArgumentException('La reserva de stock no está activa.');
|
||||
}
|
||||
if ($reservation->expires_at !== null && ! $reservation->expires_at->isFuture()) {
|
||||
throw new StockReservationExpiredException;
|
||||
}
|
||||
|
||||
$lines = $this->lockLines($reservation);
|
||||
if ($lines->isEmpty()) {
|
||||
throw new \InvalidArgumentException('La reserva de stock no tiene inventarios.');
|
||||
}
|
||||
|
||||
$inventories = $this->lockInventories($lines);
|
||||
foreach ($lines as $line) {
|
||||
$inventory = $inventories->get($line->inventory_id)
|
||||
?? throw new \InvalidArgumentException('No se encontró el inventario reservado.');
|
||||
if ($inventory->reserved_stock < $line->quantity
|
||||
|| ($line->tracks_inventory && $inventory->real_stock < $line->quantity)) {
|
||||
throw new \InvalidArgumentException('La reserva de stock no alcanza para confirmar la compra.');
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$inventories->get($line->inventory_id)->buy(
|
||||
(int) $line->quantity,
|
||||
(bool) $line->tracks_inventory,
|
||||
);
|
||||
}
|
||||
|
||||
$reservation->update([
|
||||
'status' => StockReservation::STATUS_COMMITTED,
|
||||
'expires_at' => null,
|
||||
'committed_at' => now(),
|
||||
'released_at' => null,
|
||||
'expired_at' => null,
|
||||
'release_reason' => null,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function releaseForPurchase(
|
||||
Purchase $purchase,
|
||||
string $status = StockReservation::STATUS_RELEASED,
|
||||
?string $reason = null,
|
||||
): void {
|
||||
DB::transaction(function () use ($purchase, $status, $reason): void {
|
||||
/** @var Purchase $purchase */
|
||||
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
|
||||
if ($purchase->stock_reservation_id === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var StockReservation|null $reservation */
|
||||
$reservation = StockReservation::query()->lockForUpdate()->find($purchase->stock_reservation_id);
|
||||
if ($reservation !== null) {
|
||||
$this->finalizeLocked($reservation, $status, $reason);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function returnToCart(Purchase $purchase, Cart $cart): StockReservation
|
||||
{
|
||||
return DB::transaction(function () use ($purchase, $cart): StockReservation {
|
||||
/** @var Purchase $purchase */
|
||||
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
|
||||
/** @var Cart $cart */
|
||||
$cart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
|
||||
|
||||
if ($purchase->stock_reservation_id === null
|
||||
|| $cart->current_stock_reservation_id !== $purchase->stock_reservation_id) {
|
||||
throw new \InvalidArgumentException('La compra y el carrito no comparten la reserva activa.');
|
||||
}
|
||||
|
||||
/** @var StockReservation $reservation */
|
||||
$reservation = StockReservation::query()
|
||||
->lockForUpdate()
|
||||
->findOrFail($purchase->stock_reservation_id);
|
||||
$this->assertUsableCartReservation($reservation);
|
||||
|
||||
$purchase->update(['stock_reservation_id' => null]);
|
||||
$cart->update(['current_purchase_id' => null]);
|
||||
$reservation->update(['expires_at' => $this->expiration()]);
|
||||
|
||||
return $reservation->fresh('lines');
|
||||
});
|
||||
}
|
||||
|
||||
public function assertCartReservationUsable(Cart $cart): void
|
||||
{
|
||||
DB::transaction(function () use ($cart): void {
|
||||
/** @var Cart $cart */
|
||||
$cart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
|
||||
if ($cart->current_stock_reservation_id === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var StockReservation $reservation */
|
||||
$reservation = StockReservation::query()
|
||||
->lockForUpdate()
|
||||
->findOrFail($cart->current_stock_reservation_id);
|
||||
$this->assertUsableCartReservation($reservation);
|
||||
});
|
||||
}
|
||||
|
||||
public function releaseCurrentCartReservation(
|
||||
Cart $cart,
|
||||
string $reason = self::REASON_CART_CHANGED,
|
||||
): void {
|
||||
DB::transaction(function () use ($cart, $reason): void {
|
||||
/** @var Cart $cart */
|
||||
$cart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
|
||||
if ($cart->current_stock_reservation_id === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var StockReservation|null $reservation */
|
||||
$reservation = StockReservation::query()->lockForUpdate()->find($cart->current_stock_reservation_id);
|
||||
if ($reservation !== null) {
|
||||
$this->finalizeLocked($reservation, StockReservation::STATUS_RELEASED, $reason);
|
||||
}
|
||||
$cart->update(['current_stock_reservation_id' => null]);
|
||||
});
|
||||
}
|
||||
|
||||
public function expire(StockReservation $reservation): void
|
||||
{
|
||||
DB::transaction(function () use ($reservation): void {
|
||||
/** @var StockReservation $reservation */
|
||||
$reservation = StockReservation::query()->lockForUpdate()->findOrFail($reservation->getKey());
|
||||
if ($reservation->status !== StockReservation::STATUS_ACTIVE
|
||||
|| $reservation->expires_at === null
|
||||
|| $reservation->expires_at->isFuture()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->finalizeLocked($reservation, StockReservation::STATUS_EXPIRED, null);
|
||||
});
|
||||
}
|
||||
|
||||
public function clearExpirationForReview(Purchase $purchase): void
|
||||
{
|
||||
DB::transaction(function () use ($purchase): void {
|
||||
/** @var Purchase $purchase */
|
||||
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
|
||||
if ($purchase->stock_reservation_id === null) {
|
||||
throw new \InvalidArgumentException('La compra no tiene una reserva de stock.');
|
||||
}
|
||||
|
||||
/** @var StockReservation $reservation */
|
||||
$reservation = StockReservation::query()
|
||||
->lockForUpdate()
|
||||
->findOrFail($purchase->stock_reservation_id);
|
||||
if ($reservation->status !== StockReservation::STATUS_ACTIVE) {
|
||||
throw new \InvalidArgumentException('La reserva de stock no está activa.');
|
||||
}
|
||||
if ($reservation->expires_at !== null && ! $reservation->expires_at->isFuture()) {
|
||||
throw new StockReservationExpiredException;
|
||||
}
|
||||
|
||||
$reservation->update(['expires_at' => null]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, CartItem> $items
|
||||
* @return array<int, array{quantity: int, tracks_inventory: bool}>
|
||||
*/
|
||||
private function requirementsForItems(Collection $items): array
|
||||
{
|
||||
$requirements = [];
|
||||
foreach ($items as $item) {
|
||||
$selection = $item->selectedItem();
|
||||
if ($selection === null) {
|
||||
throw new \InvalidArgumentException('El carrito contiene un item de catálogo inexistente.');
|
||||
}
|
||||
|
||||
foreach ($this->inventory->detailedRequirementsFor($selection, (int) $item->cantidad) as $inventoryId => $requirement) {
|
||||
if (isset($requirements[$inventoryId])) {
|
||||
$requirements[$inventoryId]['quantity'] += $requirement['quantity'];
|
||||
$requirements[$inventoryId]['tracks_inventory'] =
|
||||
$requirements[$inventoryId]['tracks_inventory'] || $requirement['tracks_inventory'];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$requirements[$inventoryId] = $requirement;
|
||||
}
|
||||
}
|
||||
|
||||
ksort($requirements);
|
||||
|
||||
return $requirements;
|
||||
}
|
||||
|
||||
/** @param Collection<int, CartItem> $items */
|
||||
private function loadSelections(Collection $items): void
|
||||
{
|
||||
$items->load([
|
||||
'catalogItem.inventory',
|
||||
'catalogItem.bundleComponents.catalogItem.inventory',
|
||||
'catalogItem.bundleComponents.variant.inventory',
|
||||
'catalogItem.bundleComponents.variant.catalogItem',
|
||||
'variant.inventory',
|
||||
'variant.catalogItem',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return Collection<int, StockReservationLine> */
|
||||
private function lockLines(StockReservation $reservation): Collection
|
||||
{
|
||||
return StockReservationLine::query()
|
||||
->where('stock_reservation_id', $reservation->getKey())
|
||||
->orderBy('inventory_id')
|
||||
->lockForUpdate()
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, StockReservationLine> $lines
|
||||
* @return Collection<int, Inventory>
|
||||
*/
|
||||
private function lockInventories(Collection $lines): Collection
|
||||
{
|
||||
return Inventory::query()
|
||||
->whereKey($lines->pluck('inventory_id'))
|
||||
->orderBy('id')
|
||||
->lockForUpdate()
|
||||
->get()
|
||||
->keyBy('id');
|
||||
}
|
||||
|
||||
private function finalizeLocked(
|
||||
StockReservation $reservation,
|
||||
string $status,
|
||||
?string $reason,
|
||||
): void {
|
||||
if ($reservation->status !== StockReservation::STATUS_ACTIVE) {
|
||||
return;
|
||||
}
|
||||
if (! in_array($status, [StockReservation::STATUS_RELEASED, StockReservation::STATUS_EXPIRED], true)) {
|
||||
throw new \InvalidArgumentException('El estado final de la reserva no es válido.');
|
||||
}
|
||||
|
||||
$lines = $this->lockLines($reservation);
|
||||
$inventories = $this->lockInventories($lines);
|
||||
foreach ($lines as $line) {
|
||||
$inventory = $inventories->get($line->inventory_id)
|
||||
?? throw new \InvalidArgumentException('No se encontró el inventario reservado.');
|
||||
$inventory->release((int) $line->quantity);
|
||||
}
|
||||
|
||||
$now = now();
|
||||
$reservation->update([
|
||||
'status' => $status,
|
||||
'expires_at' => null,
|
||||
'released_at' => $status === StockReservation::STATUS_RELEASED ? $now : null,
|
||||
'expired_at' => $status === StockReservation::STATUS_EXPIRED ? $now : null,
|
||||
'release_reason' => $status === StockReservation::STATUS_RELEASED ? $reason : null,
|
||||
]);
|
||||
|
||||
if ($status === StockReservation::STATUS_RELEASED) {
|
||||
Cart::query()
|
||||
->where('current_stock_reservation_id', $reservation->getKey())
|
||||
->update(['current_stock_reservation_id' => null]);
|
||||
}
|
||||
}
|
||||
|
||||
private function assertUsableCartReservation(StockReservation $reservation): void
|
||||
{
|
||||
if ($reservation->status === StockReservation::STATUS_EXPIRED
|
||||
|| ($reservation->expires_at !== null && ! $reservation->expires_at->isFuture())) {
|
||||
throw new StockReservationExpiredException;
|
||||
}
|
||||
|
||||
if ($reservation->status !== StockReservation::STATUS_ACTIVE
|
||||
|| $reservation->expires_at === null) {
|
||||
throw new \InvalidArgumentException('La reserva de stock no está disponible para operar el carrito.');
|
||||
}
|
||||
}
|
||||
|
||||
private function expiration(): Carbon
|
||||
{
|
||||
return now()->addMinutes(
|
||||
max(1, (int) config('catalog.stock_reservation_expiration_minutes', 30)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Services;
|
||||
|
||||
use App\Domains\Catalog\Models\BundleComponent;
|
||||
use App\Domains\Catalog\Models\Inventory;
|
||||
use App\Domains\Catalog\Models\StockReservation;
|
||||
use App\Domains\Catalog\Models\StockReservationLine;
|
||||
use App\Domains\Catalog\Models\Variant;
|
||||
use App\Domains\Event\Models\EventDate;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class VariantReplacementService
|
||||
{
|
||||
/** @return Collection<int, Variant> */
|
||||
public function replaceEventDate(EventDate $source, EventDate $destination): Collection
|
||||
{
|
||||
$variants = Variant::query()
|
||||
->whereNull('sales_disabled_at')
|
||||
->whereNull('replaced_by_variant_id')
|
||||
->where(function ($query) use ($source): void {
|
||||
$query->where('event_date_id', $source->getKey())
|
||||
->orWhereHas('eventDates', fn ($eventDates) => $eventDates
|
||||
->where('event_dates.id', $source->getKey()));
|
||||
})
|
||||
->with(['eventDates', 'eventDate', 'definitions', 'allAttachments'])
|
||||
->orderBy('id')
|
||||
->lockForUpdate()
|
||||
->get();
|
||||
|
||||
return $variants->map(function (Variant $variant) use ($source, $destination): Variant {
|
||||
$destinationDateIds = $variant->selectedEventDates()
|
||||
->pluck('id')
|
||||
->map(fn ($id): int => (int) $id === (int) $source->getKey()
|
||||
? (int) $destination->getKey()
|
||||
: (int) $id)
|
||||
->unique()
|
||||
->sort()
|
||||
->values();
|
||||
|
||||
$replacement = $this->findEquivalent($variant, $destinationDateIds)
|
||||
?? $this->cloneWithDates($variant, $destinationDateIds);
|
||||
|
||||
$variant->update([
|
||||
'replaced_by_variant_id' => $replacement->getKey(),
|
||||
'sales_disabled_at' => now(),
|
||||
]);
|
||||
|
||||
BundleComponent::query()
|
||||
->where('component_variant_id', $variant->getKey())
|
||||
->update(['component_variant_id' => $replacement->getKey()]);
|
||||
|
||||
return $replacement;
|
||||
})->values();
|
||||
}
|
||||
|
||||
public function disableForSuspension(EventDate $eventDate): void
|
||||
{
|
||||
$variants = Variant::query()
|
||||
->whereNull('sales_disabled_at')
|
||||
->whereNull('replaced_by_variant_id')
|
||||
->where(function ($query) use ($eventDate): void {
|
||||
$query->where('event_date_id', $eventDate->getKey())
|
||||
->orWhereHas('eventDates', fn ($eventDates) => $eventDates
|
||||
->where('event_dates.id', $eventDate->getKey()));
|
||||
})
|
||||
->with(['eventDates', 'eventDate', 'definitions', 'allAttachments'])
|
||||
->orderBy('id')
|
||||
->lockForUpdate()
|
||||
->get();
|
||||
|
||||
foreach ($variants as $variant) {
|
||||
$remainingDateIds = $variant->selectedEventDates()
|
||||
->filter(fn (EventDate $date): bool => $date->suspended_at === null
|
||||
&& $date->rescheduled_to_event_date_id === null)
|
||||
->pluck('id')
|
||||
->map(fn ($id): int => (int) $id)
|
||||
->unique()
|
||||
->sort()
|
||||
->values();
|
||||
|
||||
if ($remainingDateIds->isEmpty()) {
|
||||
$variant->update(['sales_disabled_at' => now()]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$replacement = $this->findEquivalent($variant, $remainingDateIds);
|
||||
if ($replacement === null) {
|
||||
$replacement = $this->cloneWithDates($variant, $remainingDateIds);
|
||||
} else {
|
||||
$this->mergeInventoryInto($variant, $replacement);
|
||||
}
|
||||
|
||||
$variant->update([
|
||||
'replaced_by_variant_id' => $replacement->getKey(),
|
||||
'sales_disabled_at' => now(),
|
||||
]);
|
||||
|
||||
BundleComponent::query()
|
||||
->where('component_variant_id', $variant->getKey())
|
||||
->update(['component_variant_id' => $replacement->getKey()]);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param Collection<int, int> $eventDateIds */
|
||||
private function findEquivalent(Variant $source, Collection $eventDateIds): ?Variant
|
||||
{
|
||||
$definitionSignature = $this->definitionSignature($source);
|
||||
$dateSignature = $eventDateIds->map(fn ($id): int => (int) $id)->sort()->values()->all();
|
||||
|
||||
return Variant::query()
|
||||
->where('catalog_item_id', $source->catalog_item_id)
|
||||
->whereKeyNot($source->getKey())
|
||||
->whereNull('sales_disabled_at')
|
||||
->whereNull('replaced_by_variant_id')
|
||||
->with(['eventDates', 'eventDate', 'definitions'])
|
||||
->orderBy('id')
|
||||
->lockForUpdate()
|
||||
->get()
|
||||
->first(fn (Variant $candidate): bool => $this->definitionSignature($candidate) === $definitionSignature
|
||||
&& $candidate->selectedEventDates()
|
||||
->pluck('id')
|
||||
->map(fn ($id): int => (int) $id)
|
||||
->sort()
|
||||
->values()
|
||||
->all() === $dateSignature
|
||||
);
|
||||
}
|
||||
|
||||
/** @param Collection<int, int> $eventDateIds */
|
||||
private function cloneWithDates(Variant $source, Collection $eventDateIds): Variant
|
||||
{
|
||||
$replacementInventory = $this->cloneInventory($source);
|
||||
$replacement = $source->replicate([
|
||||
'event_date_id',
|
||||
'inventory_id',
|
||||
'replaced_by_variant_id',
|
||||
'sales_disabled_at',
|
||||
]);
|
||||
$replacement->inventory_id = $replacementInventory->getKey();
|
||||
$replacement->event_date_id = $eventDateIds->count() === 1
|
||||
? $eventDateIds->first()
|
||||
: null;
|
||||
$replacement->save();
|
||||
$replacement->eventDates()->sync($eventDateIds->all());
|
||||
|
||||
$replacement->definitions()->createMany(
|
||||
$source->definitions
|
||||
->map(fn ($definition): array => [
|
||||
'item_attribute_id' => $definition->item_attribute_id,
|
||||
'value' => $definition->value,
|
||||
])
|
||||
->all(),
|
||||
);
|
||||
|
||||
$attachments = $source->allAttachments
|
||||
->mapWithKeys(fn ($attachment): array => [
|
||||
$attachment->getKey() => [
|
||||
'orden' => $attachment->pivot->orden,
|
||||
'is_enabled' => $attachment->pivot->is_enabled,
|
||||
],
|
||||
])
|
||||
->all();
|
||||
$replacement->allAttachments()->sync($attachments);
|
||||
|
||||
return $replacement->load(['eventDates', 'eventDate', 'definitions', 'allAttachments']);
|
||||
}
|
||||
|
||||
private function cloneInventory(Variant $source): Inventory
|
||||
{
|
||||
$activeLines = StockReservationLine::query()
|
||||
->where('inventory_id', $source->inventory_id)
|
||||
->whereHas('reservation', fn ($reservation) => $reservation
|
||||
->where('status', StockReservation::STATUS_ACTIVE))
|
||||
->orderBy('id')
|
||||
->lockForUpdate()
|
||||
->get();
|
||||
$sourceInventory = Inventory::query()
|
||||
->whereKey($source->inventory_id)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
$reservedStock = (int) $activeLines->sum('quantity');
|
||||
|
||||
if ($sourceInventory->reserved_stock !== $reservedStock) {
|
||||
throw new \LogicException('El inventario reservado de la variante es inconsistente.');
|
||||
}
|
||||
|
||||
$replacementInventory = Inventory::query()->create([
|
||||
'sold_units' => $sourceInventory->sold_units,
|
||||
'refunded_units' => $sourceInventory->refunded_units,
|
||||
'reserved_stock' => $reservedStock,
|
||||
'real_stock' => $sourceInventory->real_stock,
|
||||
]);
|
||||
|
||||
if ($activeLines->isNotEmpty()) {
|
||||
StockReservationLine::query()
|
||||
->whereKey($activeLines->modelKeys())
|
||||
->update(['inventory_id' => $replacementInventory->getKey()]);
|
||||
}
|
||||
$sourceInventory->update(['reserved_stock' => 0]);
|
||||
|
||||
return $replacementInventory;
|
||||
}
|
||||
|
||||
private function mergeInventoryInto(Variant $source, Variant $destination): void
|
||||
{
|
||||
if ($source->inventory_id === $destination->inventory_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$inventories = Inventory::query()
|
||||
->whereKey([$source->inventory_id, $destination->inventory_id])
|
||||
->orderBy('id')
|
||||
->lockForUpdate()
|
||||
->get()
|
||||
->keyBy('id');
|
||||
$sourceInventory = $inventories->get($source->inventory_id);
|
||||
$destinationInventory = $inventories->get($destination->inventory_id);
|
||||
if ($sourceInventory === null || $destinationInventory === null) {
|
||||
throw new \LogicException('No se encontró el inventario de una variante.');
|
||||
}
|
||||
|
||||
$activeLines = StockReservationLine::query()
|
||||
->where('inventory_id', $source->inventory_id)
|
||||
->whereHas('reservation', fn ($reservation) => $reservation
|
||||
->where('status', StockReservation::STATUS_ACTIVE))
|
||||
->orderBy('id')
|
||||
->lockForUpdate()
|
||||
->get();
|
||||
if ($sourceInventory->reserved_stock !== (int) $activeLines->sum('quantity')) {
|
||||
throw new \LogicException('El inventario reservado de la variante es inconsistente.');
|
||||
}
|
||||
|
||||
$destinationInventory->update([
|
||||
'real_stock' => $destinationInventory->real_stock + $sourceInventory->real_stock,
|
||||
'reserved_stock' => $destinationInventory->reserved_stock + $sourceInventory->reserved_stock,
|
||||
'sold_units' => $destinationInventory->sold_units + $sourceInventory->sold_units,
|
||||
'refunded_units' => $destinationInventory->refunded_units + $sourceInventory->refunded_units,
|
||||
]);
|
||||
if ($activeLines->isNotEmpty()) {
|
||||
StockReservationLine::query()
|
||||
->whereKey($activeLines->modelKeys())
|
||||
->update(['inventory_id' => $destinationInventory->getKey()]);
|
||||
}
|
||||
$sourceInventory->update([
|
||||
'real_stock' => 0,
|
||||
'reserved_stock' => 0,
|
||||
'sold_units' => 0,
|
||||
'refunded_units' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function definitionSignature(Variant $variant): array
|
||||
{
|
||||
return $variant->definitions
|
||||
->map(fn ($definition): string => $definition->item_attribute_id.'\0'.$definition->value)
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Services;
|
||||
|
||||
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 Illuminate\Support\Collection;
|
||||
|
||||
class VariantSelectionService
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $selectedValues
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function options(
|
||||
CatalogItem $catalogItem,
|
||||
array $selectedValues,
|
||||
?int $includedVariantId = null,
|
||||
): array {
|
||||
$catalogItem->load([
|
||||
'itemAttributes.attribute',
|
||||
'variants' => fn ($query) => $query->orderBy('id'),
|
||||
'variants.inventory',
|
||||
'variants.eventDate',
|
||||
'variants.eventDates',
|
||||
'variants.definitions' => fn ($query) => $query->orderBy('id'),
|
||||
'variants.definitions.itemAttribute.attribute.options',
|
||||
]);
|
||||
|
||||
$variants = $catalogItem->visibleVariants($includedVariantId)
|
||||
->values();
|
||||
$normalizedSelections = collect($selectedValues)
|
||||
->mapWithKeys(fn ($value, string $key): array => [$key => $this->normalizeValue($value)])
|
||||
->filter(fn ($value): bool => $value !== null && $value !== '' && $value !== [])
|
||||
->all();
|
||||
$matchingVariants = $variants
|
||||
->filter(fn (Variant $variant): bool => $this->matches($variant, $normalizedSelections))
|
||||
->values();
|
||||
$attributeKeys = $this->attributeKeys($catalogItem, $catalogItem->variants->values());
|
||||
$isComplete = $attributeKeys->isNotEmpty()
|
||||
&& $attributeKeys->every(fn (string $key): bool => array_key_exists($key, $normalizedSelections));
|
||||
$resolvedVariant = $isComplete && $matchingVariants->count() === 1
|
||||
? $matchingVariants->first()
|
||||
: null;
|
||||
|
||||
return [
|
||||
'variants' => $variants
|
||||
->map(fn (Variant $variant): array => $this->variantData($catalogItem, $variant))
|
||||
->values()
|
||||
->all(),
|
||||
'selectors' => $this->selectors(
|
||||
$catalogItem,
|
||||
$variants,
|
||||
$attributeKeys,
|
||||
$matchingVariants->isEmpty() ? [] : $normalizedSelections,
|
||||
),
|
||||
'selected_values' => (object) ($matchingVariants->isEmpty() ? [] : $normalizedSelections),
|
||||
'resolved_variant' => $resolvedVariant === null
|
||||
? null
|
||||
: $this->variantData($catalogItem, $resolvedVariant),
|
||||
'valid' => $matchingVariants->isNotEmpty(),
|
||||
'available_variant_count' => $variants->count(),
|
||||
'matching_variant_count' => $matchingVariants->count(),
|
||||
'price_range' => $this->priceRange($catalogItem, $variants),
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $selectedValues */
|
||||
private function matches(Variant $variant, array $selectedValues): bool
|
||||
{
|
||||
$variantValues = $variant->selectionValues();
|
||||
|
||||
foreach ($selectedValues as $key => $selectedValue) {
|
||||
if (! $variantValues->has($key)
|
||||
|| $this->valueKey($variantValues->get($key)) !== $this->valueKey($selectedValue)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @param Collection<int, Variant> $variants */
|
||||
private function attributeKeys(CatalogItem $catalogItem, Collection $variants): Collection
|
||||
{
|
||||
return $variants
|
||||
->flatMap(fn (Variant $variant): array => $variant
|
||||
->selectorOptions($catalogItem->itemAttributes)
|
||||
->keys()
|
||||
->all())
|
||||
->unique()
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Variant> $variants
|
||||
* @param Collection<int, string> $attributeKeys
|
||||
* @param array<string, mixed> $selectedValues
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function selectors(
|
||||
CatalogItem $catalogItem,
|
||||
Collection $variants,
|
||||
Collection $attributeKeys,
|
||||
array $selectedValues,
|
||||
): array {
|
||||
return $attributeKeys
|
||||
->map(function (string $key, int $index) use (
|
||||
$catalogItem,
|
||||
$variants,
|
||||
$attributeKeys,
|
||||
$selectedValues,
|
||||
): array {
|
||||
$previousKeys = $attributeKeys->take($index);
|
||||
$previousSelections = collect($selectedValues)
|
||||
->only($previousKeys->all())
|
||||
->all();
|
||||
$compatibleVariants = $variants
|
||||
->filter(fn (Variant $variant): bool => $this->matches($variant, $previousSelections));
|
||||
|
||||
return [
|
||||
'key' => $key,
|
||||
'label' => $this->attributeLabel($catalogItem, $key),
|
||||
'options' => $this->optionsFor($catalogItem, $compatibleVariants, $key),
|
||||
'enabled' => $index === 0 || $previousKeys->every(
|
||||
fn (string $previousKey): bool => array_key_exists($previousKey, $selectedValues),
|
||||
),
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Variant> $variants
|
||||
* @return list<mixed>
|
||||
*/
|
||||
private function optionsFor(CatalogItem $catalogItem, Collection $variants, string $key): array
|
||||
{
|
||||
$options = [];
|
||||
$seen = [];
|
||||
|
||||
foreach ($variants as $variant) {
|
||||
$option = $variant->selectorOptions($catalogItem->itemAttributes)->get($key);
|
||||
if ($option === null || $option === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$optionKey = $this->valueKey($option);
|
||||
if (isset($seen[$optionKey])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$seen[$optionKey] = true;
|
||||
$options[] = $option;
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
private function attributeLabel(CatalogItem $catalogItem, string $key): string
|
||||
{
|
||||
if ($key === 'event_date') {
|
||||
return 'Fecha';
|
||||
}
|
||||
|
||||
return $catalogItem->itemAttributes
|
||||
->first(fn (ItemAttribute $itemAttribute): bool => $itemAttribute->attribute?->codigo === $key)
|
||||
?->attribute
|
||||
?->nombre ?? str($key)->headline()->toString();
|
||||
}
|
||||
|
||||
/** @param Collection<int, Variant> $variants */
|
||||
private function priceRange(CatalogItem $catalogItem, Collection $variants): array
|
||||
{
|
||||
$prices = $variants
|
||||
->map(fn (Variant $variant): float => $variant->getPrice())
|
||||
->whenEmpty(fn (Collection $prices): Collection => $prices->push($catalogItem->getPrice()));
|
||||
|
||||
return [
|
||||
'minimum' => number_format((float) $prices->min(), 2, '.', ''),
|
||||
'maximum' => number_format((float) $prices->max(), 2, '.', ''),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function variantData(CatalogItem $catalogItem, Variant $variant): array
|
||||
{
|
||||
return [
|
||||
'id' => $variant->id,
|
||||
'descripcion' => $variant->getDescription(),
|
||||
'precio' => number_format($variant->getPrice(), 2, '.', ''),
|
||||
'stock_tecnico' => $catalogItem->inventory_policy === InventoryPolicy::Unlimited
|
||||
? null
|
||||
: $variant->inventory?->availableStock(),
|
||||
'values' => $variant->selectorOptions($catalogItem->itemAttributes),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeValue(mixed $value): mixed
|
||||
{
|
||||
if (is_array($value) && array_key_exists('value', $value)) {
|
||||
return (string) $value['value'];
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
return array_map(fn ($item) => $this->normalizeValue($item), $value);
|
||||
}
|
||||
|
||||
return is_scalar($value) ? (string) $value : null;
|
||||
}
|
||||
|
||||
private function valueKey(mixed $value): string
|
||||
{
|
||||
$normalized = $this->normalizeValue($value);
|
||||
|
||||
if (is_array($normalized)) {
|
||||
sort($normalized);
|
||||
}
|
||||
|
||||
return json_encode($normalized, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user