'tipo', 'sector' => 'sector', 'row' => 'fila', 'seat' => 'asiento', ]; private const ROW_ATTRIBUTE_MAP = [ 'type' => 'tipo', 'sector' => 'sector', 'row' => 'fila', ]; 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> $rows */ public function syncRows(Tenant $tenant, array $rows): CatalogItem { DB::transaction(function () use ($tenant, $rows): void { $entry = $this->entryQuery($tenant)->lockForUpdate()->firstOrFail(); $itemAttributes = $this->itemAttributes($entry); $existingVariants = $entry->variants() ->with(['inventory', 'definitions.itemAttribute.attribute']) ->lockForUpdate() ->get(); $existingBySelection = $existingVariants->keyBy( fn (Variant $variant): string => $this->selectionKey($variant->selectionValues()->all()), ); $desiredSelections = collect(); foreach (array_values($rows) as $index => $data) { $rowValues = $this->resolveRowValues($itemAttributes, $data, $index); foreach (range(1, (int) $data['max_seat']) as $seat) { $values = [ ...$rowValues, 'asiento' => $this->resolveAttributeValue( $itemAttributes, 'asiento', (string) $seat, "rows.{$index}.max_seat", ), ]; $selectionKey = $this->selectionKey($values); $desiredSelections->put($selectionKey, true); $variant = $existingBySelection->get($selectionKey); if ($variant === null) { $variant = $entry->variants()->create([ 'inventory_id' => Inventory::query()->create(['real_stock' => 1])->id, 'descripcion' => $this->description($values), 'precio' => $data['price'], ]); $variant->definitions()->createMany( collect($values)->map( fn (string $value, string $code): array => [ 'item_attribute_id' => $itemAttributes[$code]->id, 'value' => $value, ], )->values()->all(), ); } else { $variant->update([ 'descripcion' => $this->description($values), 'precio' => $data['price'], ]); } } } foreach ($existingVariants as $variant) { $selectionKey = $this->selectionKey($variant->selectionValues()->all()); if ($desiredSelections->has($selectionKey)) { continue; } $this->assertVariantCanChangeIdentity($variant, 'rows'); $variant->delete(); } $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([ 'rows' => [ 'Faltan atributos requeridos para las entradas del desfile: '.$missing->implode(', ').'.', ], ]); } return $attributes; } /** * @param Collection $itemAttributes * @param array $data * @return array */ private function resolveRowValues(Collection $itemAttributes, array $data, int $index): array { $values = []; foreach (self::ROW_ATTRIBUTE_MAP as $input => $code) { $values[$code] = $this->resolveAttributeValue( $itemAttributes, $code, (string) $data[$input], "rows.{$index}.{$input}", ); } return $values; } /** @param Collection $itemAttributes */ private function resolveAttributeValue( Collection $itemAttributes, string $code, string $requestedValue, string $errorKey, ): string { $requestedValue = trim($requestedValue); $option = $itemAttributes[$code]->attribute->options->first( fn ($candidate): bool => $this->normalize($candidate->value) === $this->normalize($requestedValue), ); if ($option === null) { throw ValidationException::withMessages([ $errorKey => ['La opción seleccionada no es válida.'], ]); } return $option->value; } /** @param array $values */ private function selectionKey(array $values): string { return collect(self::ATTRIBUTE_MAP) ->map(fn (string $code): string => $this->normalize((string) ($values[$code] ?? ''))) ->implode('|'); } 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); } } }