'tipo', 'sector' => 'sector', 'row' => 'fila', 'seat' => 'asiento', ]; public function __construct(private readonly AttachmentService $attachmentService) {} public function current(Tenant $tenant): CatalogItem { return $this->entryQuery($tenant) ->with([ 'allAttachments', 'itemAttributes.attribute.options', 'variants' => fn ($query) => $query->orderBy('id'), 'variants.inventory', 'variants.definitions.itemAttribute.attribute', ]) ->firstOrFail(); } /** * @param array> $variants */ public function syncVariants(Tenant $tenant, array $variants): CatalogItem { DB::transaction(function () use ($tenant, $variants): void { $entry = $this->entryQuery($tenant)->lockForUpdate()->firstOrFail(); $itemAttributes = $this->itemAttributes($entry); $existingVariants = $entry->variants() ->with(['inventory', 'definitions.itemAttribute.attribute']) ->lockForUpdate() ->get(); $incomingIds = collect($variants) ->pluck('id') ->filter() ->map(fn ($id): int => (int) $id) ->values(); foreach ($existingVariants->whereNotIn('id', $incomingIds) as $variant) { $this->assertVariantCanChangeIdentity($variant, 'variants'); $variant->delete(); } foreach (array_values($variants) as $index => $data) { $values = $this->resolveValues($itemAttributes, $data, $index); $variant = isset($data['id']) ? $existingVariants->firstWhere('id', (int) $data['id']) : null; if (isset($data['id']) && $variant === null) { throw ValidationException::withMessages([ "variants.{$index}.id" => [ 'La variante no pertenece al producto Entrada del desfile.', ], ]); } if ($variant === null) { $variant = $entry->variants()->create([ 'inventory_id' => Inventory::query()->create(['real_stock' => 1])->id, 'descripcion' => $this->description($values), 'precio' => $data['price'], ]); } else { if ($this->identityChanged($variant, $values)) { $this->assertVariantCanChangeIdentity($variant, "variants.{$index}"); } $variant->update([ 'descripcion' => $this->description($values), 'precio' => $data['price'], ]); $variant->definitions()->delete(); } $variant->definitions()->createMany( collect($values)->map( fn (string $value, string $code): array => [ 'item_attribute_id' => $itemAttributes[$code]->id, 'value' => $value, ], )->values()->all(), ); } $minimumPrice = $entry->variants()->min('precio'); if ($minimumPrice !== null) { $entry->update(['precio' => $minimumPrice]); } }); return $this->current($tenant); } public function replaceImage( Tenant $tenant, UploadedFile $image, bool $isEnabled, ): CatalogItem { $attachment = $this->attachmentService->store($image, 'catalog-items'); $previousAttachments = collect(); try { DB::transaction(function () use ($tenant, $attachment, $isEnabled, &$previousAttachments): void { $entry = $this->entryQuery($tenant)->lockForUpdate()->firstOrFail(); $previousAttachments = $entry->allAttachments()->get(); $entry->allAttachments()->sync([ $attachment->id => [ 'orden' => 0, 'is_enabled' => $isEnabled, ], ]); }); } catch (Throwable $throwable) { $this->deleteAttachmentQuietly($attachment); throw $throwable; } $previousAttachments->each(fn (Attachment $previous) => $this->deleteIfUnused($previous)); return $this->current($tenant); } public function updateImage(Tenant $tenant, bool $isEnabled): CatalogItem { DB::transaction(function () use ($tenant, $isEnabled): void { $entry = $this->entryQuery($tenant)->lockForUpdate()->firstOrFail(); $attachment = $entry->allAttachments()->lockForUpdate()->firstOrFail(); $entry->allAttachments()->updateExistingPivot($attachment->id, [ 'is_enabled' => $isEnabled, ]); }); return $this->current($tenant); } public function deleteImage(Tenant $tenant): void { $attachment = DB::transaction(function () use ($tenant): Attachment { $entry = $this->entryQuery($tenant)->lockForUpdate()->firstOrFail(); $attachment = $entry->allAttachments()->lockForUpdate()->firstOrFail(); $entry->allAttachments()->detach($attachment->id); return $attachment; }); $this->deleteIfUnused($attachment); } /** @return Collection */ private function itemAttributes(CatalogItem $entry): Collection { $attributes = $entry->itemAttributes() ->with('attribute.options') ->get() ->filter(fn (ItemAttribute $itemAttribute): bool => $itemAttribute->attribute !== null) ->keyBy(fn (ItemAttribute $itemAttribute): string => $itemAttribute->attribute->codigo); $missing = collect(self::ATTRIBUTE_MAP)->diff($attributes->keys()); if ($missing->isNotEmpty()) { throw ValidationException::withMessages([ 'variants' => [ 'Faltan atributos requeridos para las entradas del desfile: '.$missing->implode(', ').'.', ], ]); } return $attributes; } /** * @param Collection $itemAttributes * @param array $data * @return array */ private function resolveValues(Collection $itemAttributes, array $data, int $index): array { $values = []; foreach (self::ATTRIBUTE_MAP as $input => $code) { $requestedValue = trim((string) $data[$input]); $option = $itemAttributes[$code]->attribute->options->first( fn ($candidate): bool => $this->normalize($candidate->value) === $this->normalize($requestedValue), ); if ($option === null) { throw ValidationException::withMessages([ "variants.{$index}.{$input}" => ['La opción seleccionada no es válida.'], ]); } $values[$code] = $option->value; } return $values; } /** @param array $values */ private function identityChanged(Variant $variant, array $values): bool { $currentValues = $variant->definitions ->mapWithKeys(fn ($definition): array => [ $definition->itemAttribute?->attribute?->codigo => $definition->value, ]); return collect($values)->contains( fn (string $value, string $code): bool => $this->normalize((string) $currentValues->get($code)) !== $this->normalize($value), ); } private function assertVariantCanChangeIdentity(Variant $variant, string $key): void { $inventory = $variant->inventory; if (($inventory?->reserved_stock ?? 0) > 0 || ($inventory?->sold_units ?? 0) > 0) { throw ValidationException::withMessages([ $key => [ 'No se puede modificar ni eliminar un asiento con ventas o reservas.', ], ]); } } /** @param array $values */ private function description(array $values): string { return "Sector {$values['sector']} - Fila {$values['fila']} - Asiento {$values['asiento']} - {$values['tipo']}"; } private function normalize(string $value): string { return Str::ascii(mb_strtolower(trim($value))); } /** @return Builder */ private function entryQuery(Tenant $tenant): Builder { return CatalogItem::query() ->where('tenant_code', $tenant->codigo) ->where('slug', 'entrada'); } private function deleteIfUnused(Attachment $attachment): void { if (DB::table('catalog_items_attachments')->where('attachment_id', $attachment->id)->exists()) { return; } $this->deleteAttachmentQuietly($attachment); } private function deleteAttachmentQuietly(Attachment $attachment): void { try { $this->attachmentService->delete($attachment); } catch (Throwable $throwable) { report($throwable); } } }