refactor(catalog): Complete catalog refactor to simplify its data model and its querying.

source commits: refactor/catalog
This commit is contained in:
2026-07-21 09:23:19 -03:00
parent d3182fbd13
commit 29a2ca19a3
116 changed files with 5141 additions and 5217 deletions

View File

@@ -0,0 +1,218 @@
<?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
{
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->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,
];
}
}

View File

@@ -0,0 +1,469 @@
<?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\Inventory;
use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant;
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'] ?? [];
$components = $data['components'] ?? [];
$hasDirectStock = array_key_exists('real_stock', $data);
$realStock = (int) ($data['real_stock'] ?? 0);
$hasVariants = $attributeCodes !== [];
if ($type === CatalogItemType::Bundle) {
$this->validateBundleData($data, $components);
} else {
if (array_key_exists('components', $data)) {
throw ValidationException::withMessages([
'components' => ['Un item standard no puede tener componentes.'],
]);
}
$this->validateInventoryStrategy(
$data,
$variants,
$hasVariants,
$hasDirectStock,
);
}
unset(
$data['variants'],
$data['images'],
$data['attribute_codes'],
$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;
$data['minimum_use_date'] = null;
$data['maximum_use_date'] = null;
} 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)
: [];
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.definitions.itemAttribute.attribute',
'bundleComponents.catalogItem',
'bundleComponents.variant.catalogItem',
]);
});
}
public function getDetail(CatalogItem $catalogItem, ?int $variantId = null): CatalogItem
{
$catalogItem->load([
'attachments',
'inventory',
'category',
'brand',
'itemAttributes.attribute.options',
'variants' => fn ($query) => $query->orderBy('id'),
'variants.inventory',
'variants.attachments',
'variants.definitions' => fn ($query) => $query->orderBy('id'),
'variants.definitions.itemAttribute.attribute',
'bundleComponents.catalogItem.inventory',
'bundleComponents.variant.inventory',
'bundleComponents.variant.definitions.itemAttribute.attribute',
]);
$selectedVariant = $variantId === null
? $catalogItem->variants->first()
: $catalogItem->variants->firstWhere('id', $variantId);
if ($variantId !== null && $selectedVariant === null) {
throw new NotFoundHttpException('Variant not found for catalog item.');
}
$catalogItem->setRelation('selectedVariant', $selectedVariant);
return $catalogItem;
}
public function delete(CatalogItem $catalogItem): void
{
DB::transaction(function () use ($catalogItem): void {
$catalogItem->load([
'attachments',
'variants.attachments',
]);
$attachments = $catalogItem->attachments
->merge($catalogItem->variants->flatMap->attachments)
->unique('id');
$inventoryIds = collect([$catalogItem->inventory_id])
->merge($catalogItem->variants->pluck('inventory_id'))
->filter()
->unique();
$catalogItem->attachments()->detach();
foreach ($catalogItem->variants as $variant) {
$variant->attachments()->detach();
}
$catalogItem->delete();
Inventory::query()->whereKey($inventoryIds)->delete();
foreach ($attachments as $attachment) {
if (! DB::table('catalog_items_attachments')->where('attachment_id', $attachment->id)->exists()) {
$this->attachmentService->delete($attachment);
}
}
});
}
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}" => ['El componente esta duplicado.'],
]);
}
$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" => [
'El item no pertenece al tenant del bundle.',
],
]);
}
if ($componentItem->is($bundle) || $componentItem->type !== CatalogItemType::Standard) {
throw ValidationException::withMessages([
"components.{$index}.catalog_item_id" => [
'El componente debe ser un item standard distinto del bundle.',
],
]);
}
$hasVariants = $componentItem->variants()->exists();
if ($hasVariants && $variantId === null) {
throw ValidationException::withMessages([
"components.{$index}.variant_id" => [
'Debe seleccionar una variante para este componente.',
],
]);
}
if (! $hasVariants && $variantId !== null) {
throw ValidationException::withMessages([
"components.{$index}.variant_id" => [
'El componente con inventario directo no admite una variante.',
],
]);
}
if ($variantId !== null && ! $componentItem->variants()->whereKey($variantId)->exists()) {
throw ValidationException::withMessages([
"components.{$index}.variant_id" => [
'La variante no pertenece al componente indicado.',
],
]);
}
$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' => ['Un bundle debe tener al menos un componente.'],
]);
}
foreach ([
'real_stock',
'inventory_policy',
'attribute_codes',
'variants',
'has_tickets',
'minimum_use_date',
'maximum_use_date',
] as $field) {
if (array_key_exists($field, $data)) {
throw ValidationException::withMessages([
$field => ["{$field} no se admite para un bundle."],
]);
}
}
}
/**
* @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 => ['El attachment indicado no existe.'],
]);
}
return $attachment;
}
return $this->attachmentService->store($image, 'catalog-items');
}
/**
* @param array<int, string> $attributeCodes
* @return array<string, ItemAttribute>
*/
private function createItemAttributes(
CatalogItem $catalogItem,
array $attributeCodes,
): 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' => [
"El atributo {$attributeCode} no existe para el tenant del ítem.",
],
]);
}
$itemAttribute = $catalogItem->itemAttributes()->create([
'attribute_id' => $attribute->id,
]);
$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" => [
'inventory_id, reserved_stock y sold_units son administrados internamente.',
],
]);
}
$inventory = $this->createInventory((int) ($data['real_stock'] ?? 0));
$variant = $catalogItem->variants()->create([
'inventory_id' => $inventory->id,
]);
foreach ($data['values'] ?? [] as $attributeCode => $value) {
$itemAttribute = $itemAttributes[$attributeCode] ?? null;
if ($itemAttribute === null) {
throw ValidationException::withMessages([
"variants.{$index}.values.{$attributeCode}" => [
'El atributo no pertenece al ítem de catálogo.',
],
]);
}
$variant->definitions()->create([
'item_attribute_id' => $itemAttribute->id,
'value' => $value,
]);
}
return $variant;
}
/**
* @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' => [
'Un ítem con attribute_codes debe tener variantes.',
],
]);
}
if (! $hasVariants && $variants !== []) {
throw ValidationException::withMessages([
'variants' => [
'Un ítem sin attribute_codes no puede tener variantes.',
],
]);
}
if ($hasVariants && $hasDirectStock) {
throw ValidationException::withMessages([
'real_stock' => [
'Un ítem con variantes no puede tener inventario directo.',
],
]);
}
if (
array_key_exists('inventory_id', $data)
|| array_key_exists('reserved_stock', $data)
|| array_key_exists('sold_units', $data)
) {
throw ValidationException::withMessages([
'inventory' => [
'inventory_id, reserved_stock y sold_units son administrados internamente.',
],
]);
}
}
}

View File

@@ -1,25 +0,0 @@
<?php
namespace App\Domains\Catalog\Services;
use App\Domains\Catalog\Models\FeaturedGroup;
class FeaturedGroupService
{
public function createGroup(string $tenantCodigo, array $data): FeaturedGroup
{
$data['tenant_codigo'] = $tenantCodigo;
return FeaturedGroup::create($data);
}
public function updateGroup(FeaturedGroup $group, array $data): FeaturedGroup
{
$group->update($data);
return $group;
}
public function deleteGroup(FeaturedGroup $group): bool|null
{
return $group->delete();
}
}

View File

@@ -1,390 +0,0 @@
<?php
namespace App\Domains\Catalog\Services;
use App\Domains\Attachable\Services\AttachmentService;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\Product;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ProductService
{
public function __construct(protected AttachmentService $attachmentService) {}
/**
* Create a product.
*
* @param array<string, mixed> $data
*/
public function create(Tenant $tenant, array $data): Product
{
return DB::transaction(function () use ($tenant, $data) {
$attributeIds = $data['attribute_ids'] ?? [];
$images = $data['images'] ?? [];
$stock = $data['stock'] ?? 0;
$inventoryPolicy = $data['inventory_policy'] ?? InventoryPolicy::Tracked->value;
unset($data['attribute_ids'], $data['images'], $data['stock'], $data['inventory_policy']);
/** @var Product $product */
$product = Product::query()->create([
...$data,
'tenant_codigo' => $tenant->codigo,
]);
$product->attributes()->sync($attributeIds);
if (! empty($images)) {
$this->syncProductImages($product, $images);
}
// Create default variant with stock
$this->createVariant($product, [
'stock' => $stock,
'inventory_policy' => $inventoryPolicy,
'is_placeholder' => true,
'definitions' => [],
]);
return $product->load(['attributes.options', 'attachments', 'brand', 'category']);
});
}
/**
* Update a product.
*
* @param array<string, mixed> $data
*/
public function update(Product $product, array $data): Product
{
return DB::transaction(function () use ($product, $data) {
$hasAttributeIds = array_key_exists('attribute_ids', $data);
$attributeIds = $data['attribute_ids'] ?? [];
$hasImages = array_key_exists('images', $data);
$images = $data['images'] ?? [];
unset($data['attribute_ids'], $data['images']);
// Ensure tenant_codigo cannot be updated/changed
unset($data['tenant_codigo']);
$product->update($data);
if ($hasAttributeIds) {
$product->attributes()->sync($attributeIds);
}
if ($hasImages) {
$this->syncProductImages($product, $images);
}
return $product->load(['attributes.options', 'attachments', 'brand', 'category']);
});
}
/**
* Delete a product.
*/
public function delete(Product $product): void
{
DB::transaction(function () use ($product) {
foreach ($product->variants as $variant) {
$this->deleteVariantAttachments($variant);
$product->deleteVariant($variant);
}
// Delete product-level attachments from S3 and database
$existing = $product->attachments()->get();
$product->attachments()->detach();
foreach ($existing as $attachment) {
$this->attachmentService->delete($attachment);
}
$product->attributes()->detach();
$product->delete();
});
}
/**
* Create a product variant.
*
* @param array<string, mixed> $data
*/
public function createVariant(Product $product, array $data): ProductVariant
{
return DB::transaction(function () use ($product, $data) {
$images = $data['images'] ?? [];
unset($data['images']);
// Determine if the variant being created is a placeholder one
$hasDefinitions = ! empty($data['definitions']);
$isPlaceholder = $data['is_placeholder'] ?? (! $hasDefinitions);
$data['is_placeholder'] = $isPlaceholder;
// Remove any existing placeholder variants
$defaultVariants = $product->variants()->where('is_placeholder', true)->get();
foreach ($defaultVariants as $defaultVariant) {
$this->deleteVariantAttachments($defaultVariant);
$product->deleteVariant($defaultVariant);
}
$variant = $product->createVariant($data);
if (! empty($images)) {
$this->syncVariantImages($variant, $images);
}
return $variant->load(['product', 'definitions.productAttribute.attribute.options', 'attachments']);
});
}
/**
* Update a product variant.
*
* @param array<string, mixed> $data
*/
public function updateVariant(ProductVariant $variant, array $data): ProductVariant
{
return DB::transaction(function () use ($variant, $data) {
$hasImages = array_key_exists('images', $data);
$images = $data['images'] ?? [];
unset($data['images']);
/** @var Product $product */
$product = $variant->product;
$updatedVariant = $product->updateVariant($variant, $data);
if ($hasImages) {
$this->syncVariantImages($updatedVariant, $images);
}
return $updatedVariant->load(['product', 'definitions.productAttribute.attribute.options', 'attachments']);
});
}
/**
* Delete a product variant.
*/
public function deleteVariant(ProductVariant $variant): void
{
DB::transaction(function () use ($variant) {
/** @var Product $product */
$product = $variant->product;
$this->deleteVariantAttachments($variant);
$product->deleteVariant($variant);
// Re-create a default variant with stock 0 if it has no variants left
if ($product->variants()->count() === 0) {
$product->createVariant([
'stock' => 0,
'inventory_policy' => InventoryPolicy::Tracked->value,
'is_placeholder' => true,
'definitions' => [],
]);
}
});
}
/**
* Create an attribute.
*
* @param array<string, mixed> $data
*/
public static function createAttribute(Tenant $tenant, array $data): Attribute
{
return DB::transaction(function () use ($tenant, $data) {
return Product::createAttribute($tenant, $data);
});
}
/**
* Update an attribute.
*
* @param array<string, mixed> $data
*/
public static function updateAttribute(Attribute $attribute, array $data): Attribute
{
return DB::transaction(function () use ($attribute, $data) {
return Product::updateAttribute($attribute, $data);
});
}
/**
* Upload a list of image files/base64 strings and sync them to a variant.
*
* When called on update, the existing attachments are detached first so the
* final set always matches exactly what was sent in the request.
*
* @param array<int, UploadedFile|string> $images
*/
protected function syncVariantImages(ProductVariant $variant, array $images): void
{
$this->deleteVariantAttachments($variant);
$attachmentIds = [];
foreach ($images as $image) {
$attachment = $this->attachmentService->store($image, 'variants');
$attachmentIds[] = $attachment->id;
}
$variant->attachments()->sync($attachmentIds);
}
/**
* Upload a list of image files/base64 strings and sync them to a product.
*
* Same logic as syncVariantImages but for products without variants.
*
* @param array<int, UploadedFile|string> $images
*/
protected function syncProductImages(Product $product, array $images): void
{
// Detach pivot record and delete attachment from S3 and database
$existing = $product->attachments()->get();
$product->attachments()->detach();
foreach ($existing as $attachment) {
$this->attachmentService->delete($attachment);
}
$attachmentIds = [];
foreach ($images as $image) {
$attachment = $this->attachmentService->store($image, 'products');
$attachmentIds[] = $attachment->id;
}
$product->attachments()->sync($attachmentIds);
}
protected function deleteVariantAttachments(ProductVariant $variant): void
{
$existing = $variant->attachments()->get();
$variant->attachments()->detach();
foreach ($existing as $attachment) {
$this->attachmentService->delete($attachment);
}
}
/**
* Delete an attribute.
*/
public static function deleteAttribute(Attribute $attribute): void
{
DB::transaction(function () use ($attribute) {
Product::deleteAttribute($attribute);
});
}
/**
* Get products for a tenant with resolved first image (with fallback to first variant's first image).
*/
public function getProductos(Tenant $tenant): LengthAwarePaginator
{
$products = Product::query()
->where('tenant_codigo', $tenant->codigo)
->with([
'attachments' => fn ($query) => $query->orderBy('attachments.id'),
'brand',
'category',
'variants.attachments' => fn ($query) => $query->orderBy('attachments.id'),
])
->latest()
->paginateFromRequest();
foreach ($products as $product) {
$resolvedAttachment = null;
if ($product->attachments->isNotEmpty()) {
$resolvedAttachment = $product->attachments->first();
} else {
$firstVariant = $product->variants->sortBy('id')->first();
if ($firstVariant && $firstVariant->attachments->isNotEmpty()) {
$resolvedAttachment = $firstVariant->attachments->first();
}
}
$product->setRelation('attachments', $resolvedAttachment ? collect([$resolvedAttachment]) : collect());
$product->unsetRelation('variants');
}
return $products;
}
public function getProductDetail(Tenant $tenant, Product $product, ?int $variantId = null): Product
{
$product->load([
'attachments' => fn ($query) => $query->orderBy('attachments.id'),
'attributes.options',
'brand',
'category',
'variants' => fn ($query) => $query->orderBy('id'),
'variants.definitions.productAttribute.attribute.options',
]);
$this->filterProductDetailAttributeOptions($product);
$selectedVariant = $variantId !== null
? $product->variants->firstWhere('id', $variantId)
: $product->variants->first(fn (ProductVariant $variant) => $variant->isAvailableForSale());
if ($variantId !== null && $selectedVariant === null) {
throw new NotFoundHttpException('Product variant not found for product.');
}
if ($variantId !== null && ! $selectedVariant->isAvailableForSale()) {
throw ValidationException::withMessages([
'variant_id' => 'La variante seleccionada no tiene stock.',
]);
}
$selectedVariant ??= $product->variants->first();
if ($selectedVariant !== null) {
$selectedVariant->load([
'attachments' => fn ($query) => $query->orderBy('attachments.id'),
]);
$selectedVariant->setRelation('fallbackAttachments', $product->attachments);
$product->setSelectedVariant($selectedVariant);
}
return $product;
}
protected function filterProductDetailAttributeOptions(Product $product): void
{
$availableValuesByAttributeId = [];
foreach ($product->variants as $variant) {
foreach ($variant->definitions as $definition) {
$attributeId = $definition->productAttribute?->attribute_id;
if ($attributeId === null || $definition->value === null) {
continue;
}
$availableValuesByAttributeId[$attributeId][$definition->value] = true;
}
}
foreach ($product->attributes as $attribute) {
if (! $attribute->relationLoaded('options')) {
continue;
}
$availableValues = $availableValuesByAttributeId[$attribute->id] ?? [];
$attribute->setRelation(
'options',
$attribute->options
->filter(fn ($option): bool => array_key_exists($option->value, $availableValues))
->values()
);
}
}
}