refactor(catalog): Complete catalog refactor to simplify its data model and its querying.
source commits: refactor/catalog
This commit is contained in:
469
app/Domains/Catalog/Services/CatalogService.php
Normal file
469
app/Domains/Catalog/Services/CatalogService.php
Normal 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.',
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user