diff --git a/app/Domains/Commerce/Catalog/Models/CatalogItem.php b/app/Domains/Commerce/Catalog/Models/CatalogItem.php index b01ca18d..9fe5d1c6 100644 --- a/app/Domains/Commerce/Catalog/Models/CatalogItem.php +++ b/app/Domains/Commerce/Catalog/Models/CatalogItem.php @@ -2,7 +2,6 @@ namespace App\Domains\Commerce\Catalog\Models; -use App\Shared\Attachable\Models\Attachment; use App\Domains\Commerce\Catalog\Enums\CatalogItemType; use App\Domains\Commerce\Catalog\Enums\InventoryPolicy; use App\Domains\Commerce\Catalog\Enums\InventorySubject; @@ -10,6 +9,7 @@ use App\Domains\Commerce\Catalog\Services\CatalogInventoryService; use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Ticketing\Event\Models\Event; use App\Domains\Ticketing\Ticket\Models\Ticket; +use App\Shared\Attachable\Models\Attachment; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -237,7 +237,7 @@ class CatalogItem extends Model ->whereHas( 'inventory', fn (Builder $inventoryQuery): Builder => $inventoryQuery - ->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock') + ->whereRaw('inventories.real_stock > inventories.reserved_stock + inventories.entry_reserved_stock') ) ) ->orWhere(function (Builder $directItemQuery): void { @@ -249,7 +249,7 @@ class CatalogItem extends Model ->orWhereHas( 'inventory', fn (Builder $availableInventoryQuery): Builder => $availableInventoryQuery - ->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock') + ->whereRaw('inventories.real_stock > inventories.reserved_stock + inventories.entry_reserved_stock') ); }); }); @@ -264,9 +264,15 @@ class CatalogItem extends Model /** @return Collection */ public function visibleVariants(?int $includedVariantId = null): Collection { + $this->variants + ->filter(fn (Variant $variant): bool => $variant->exists) + ->loadMissing('desfileEntryReservations'); + return $this->variants ->each(fn (Variant $variant) => $variant->setRelation('catalogItem', $this)) ->filter(fn (Variant $variant): bool => $variant->hasOnlyActiveEventDates() + && (! $variant->relationLoaded('desfileEntryReservations') + || $variant->desfileEntryReservations->isEmpty()) && (($includedVariantId !== null && $variant->id === $includedVariantId) || ($variant->isSellable() && ( $this->inventory_policy === InventoryPolicy::Unlimited diff --git a/app/Domains/Commerce/Catalog/Models/Inventory.php b/app/Domains/Commerce/Catalog/Models/Inventory.php index cc7d6a33..d18af516 100644 --- a/app/Domains/Commerce/Catalog/Models/Inventory.php +++ b/app/Domains/Commerce/Catalog/Models/Inventory.php @@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne; 'sold_units', 'refunded_units', 'reserved_stock', + 'entry_reserved_stock', 'real_stock', ])] class Inventory extends Model @@ -35,6 +36,7 @@ class Inventory extends Model 'sold_units' => 'integer', 'refunded_units' => 'integer', 'reserved_stock' => 'integer', + 'entry_reserved_stock' => 'integer', 'real_stock' => 'integer', ]; } @@ -59,7 +61,26 @@ class Inventory extends Model public function availableStock(): int { - return max(0, $this->real_stock - $this->reserved_stock); + return max(0, $this->real_stock - $this->reserved_stock - $this->entry_reserved_stock); + } + + public function reserveEntry(int $amount, bool $tracksInventory): void + { + if ($amount < 1 || ($tracksInventory && $this->availableStock() < $amount)) { + throw new \InvalidArgumentException('No hay stock disponible para la reserva de entradas.'); + } + $this->entry_reserved_stock += $amount; + $this->save(); + } + + public function releaseEntry(int $amount): void + { + if ($amount < 1 || $this->entry_reserved_stock < $amount) { + throw new \InvalidArgumentException('La cantidad de entradas reservadas no es válida.'); + } + + $this->entry_reserved_stock -= $amount; + $this->save(); } public function reserve(int $amount, bool $tracksInventory): void @@ -92,7 +113,7 @@ class Inventory extends Model throw new \InvalidArgumentException('La cantidad reservada no alcanza para confirmar la compra.'); } - if ($tracksInventory && $this->real_stock < $amount) { + if ($tracksInventory && $this->real_stock - $this->entry_reserved_stock < $amount) { throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la compra.'); } diff --git a/app/Domains/Commerce/Catalog/Models/Variant.php b/app/Domains/Commerce/Catalog/Models/Variant.php index 3e63704b..dd5a622e 100644 --- a/app/Domains/Commerce/Catalog/Models/Variant.php +++ b/app/Domains/Commerce/Catalog/Models/Variant.php @@ -2,9 +2,10 @@ namespace App\Domains\Commerce\Catalog\Models; -use App\Shared\Attachable\Models\Attachment; +use App\Domains\Ticketing\Desfile\Models\EntryReservation; use App\Domains\Ticketing\Event\Models\EventDate; use App\Domains\Ticketing\Ticket\Models\Ticket; +use App\Shared\Attachable\Models\Attachment; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -74,6 +75,12 @@ class Variant extends Model return $this->hasMany(Ticket::class, 'source_variant_id'); } + /** @return HasMany */ + public function desfileEntryReservations(): HasMany + { + return $this->hasMany(EntryReservation::class); + } + /** @return BelongsTo */ public function inventory(): BelongsTo { diff --git a/app/Domains/Commerce/Catalog/Services/CatalogInventoryService.php b/app/Domains/Commerce/Catalog/Services/CatalogInventoryService.php index 0f396b63..5819fe73 100644 --- a/app/Domains/Commerce/Catalog/Services/CatalogInventoryService.php +++ b/app/Domains/Commerce/Catalog/Services/CatalogInventoryService.php @@ -153,7 +153,7 @@ class CatalogInventoryService if ($operation === 'commit' && $requirement['tracks_inventory'] - && $inventory->real_stock < $requiredQuantity) { + && $inventory->real_stock - $inventory->entry_reserved_stock < $requiredQuantity) { throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la compra.'); } } diff --git a/app/Domains/Commerce/Catalog/Services/StockReservationService.php b/app/Domains/Commerce/Catalog/Services/StockReservationService.php index 163f42b1..a9216474 100644 --- a/app/Domains/Commerce/Catalog/Services/StockReservationService.php +++ b/app/Domains/Commerce/Catalog/Services/StockReservationService.php @@ -225,7 +225,7 @@ class StockReservationService $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)) { + || ($line->tracks_inventory && $inventory->real_stock - $inventory->entry_reserved_stock < $line->quantity)) { throw new \InvalidArgumentException('La reserva de stock no alcanza para confirmar la compra.'); } } diff --git a/app/Domains/Commerce/Catalog/Services/VariantReplacementService.php b/app/Domains/Commerce/Catalog/Services/VariantReplacementService.php index 0a210ba1..317c4972 100644 --- a/app/Domains/Commerce/Catalog/Services/VariantReplacementService.php +++ b/app/Domains/Commerce/Catalog/Services/VariantReplacementService.php @@ -7,6 +7,7 @@ use App\Domains\Commerce\Catalog\Models\Inventory; use App\Domains\Commerce\Catalog\Models\StockReservation; use App\Domains\Commerce\Catalog\Models\StockReservationLine; use App\Domains\Commerce\Catalog\Models\Variant; +use App\Domains\Ticketing\Desfile\Models\EntryReservation; use App\Domains\Ticketing\Event\Models\EventDate; use Illuminate\Support\Collection; @@ -190,6 +191,7 @@ class VariantReplacementService 'sold_units' => $sourceInventory->sold_units, 'refunded_units' => $sourceInventory->refunded_units, 'reserved_stock' => $reservedStock, + 'entry_reserved_stock' => $sourceInventory->entry_reserved_stock, 'real_stock' => $sourceInventory->real_stock, ]); @@ -198,7 +200,9 @@ class VariantReplacementService ->whereKey($activeLines->modelKeys()) ->update(['inventory_id' => $replacementInventory->getKey()]); } - $sourceInventory->update(['reserved_stock' => 0]); + EntryReservation::query()->where('inventory_id', $sourceInventory->id) + ->update(['inventory_id' => $replacementInventory->id]); + $sourceInventory->update(['reserved_stock' => 0, 'entry_reserved_stock' => 0]); return $replacementInventory; } @@ -235,6 +239,7 @@ class VariantReplacementService $destinationInventory->update([ 'real_stock' => $destinationInventory->real_stock + $sourceInventory->real_stock, 'reserved_stock' => $destinationInventory->reserved_stock + $sourceInventory->reserved_stock, + 'entry_reserved_stock' => $destinationInventory->entry_reserved_stock + $sourceInventory->entry_reserved_stock, 'sold_units' => $destinationInventory->sold_units + $sourceInventory->sold_units, 'refunded_units' => $destinationInventory->refunded_units + $sourceInventory->refunded_units, ]); @@ -243,9 +248,12 @@ class VariantReplacementService ->whereKey($activeLines->modelKeys()) ->update(['inventory_id' => $destinationInventory->getKey()]); } + EntryReservation::query()->where('inventory_id', $sourceInventory->id) + ->update(['inventory_id' => $destinationInventory->id]); $sourceInventory->update([ 'real_stock' => 0, 'reserved_stock' => 0, + 'entry_reserved_stock' => 0, 'sold_units' => 0, 'refunded_units' => 0, ]); diff --git a/app/Domains/Commerce/Purchase/Services/TenantTransactionResetService.php b/app/Domains/Commerce/Purchase/Services/TenantTransactionResetService.php index 4e15105b..37931061 100644 --- a/app/Domains/Commerce/Purchase/Services/TenantTransactionResetService.php +++ b/app/Domains/Commerce/Purchase/Services/TenantTransactionResetService.php @@ -25,6 +25,9 @@ class TenantTransactionResetService 'carts' => $scope['cart_ids']->count(), 'cart_items' => $scope['cart_item_ids']->count(), 'tickets' => DB::table('tickets')->where('tenant_code', $tenantCode)->count(), + 'entry_reservations' => DB::table('desfile_entry_reservations') + ->whereIn('variant_id', DB::table('variantes')->whereIn('catalog_item_id', + DB::table('catalog_items')->where('tenant_code', $tenantCode)->select('id'))->select('id'))->count(), 'stock_reservations' => $this->reservationQuery($scope)->count(), 'purchase_changes' => DB::table('value_changes') ->where('tenant_code', $tenantCode) @@ -47,6 +50,10 @@ class TenantTransactionResetService $telepagosQr = DB::table('telepagos_qr')->whereIn('compra_id', $scope['purchase_ids'])->count(); $summary = [ 'stock_reservations_deleted' => $this->reservationQuery($scope)->delete(), + 'entry_reservations_deleted' => DB::table('desfile_entry_reservations') + ->whereIn('variant_id', DB::table('variantes')->whereIn('catalog_item_id', + DB::table('catalog_items')->where('tenant_code', $tenantCode)->select('id'))->select('id'))->delete(), + 'entry_reservation_batches_deleted' => DB::table('desfile_reservation_batches')->where('tenant_code', $tenantCode)->delete(), 'tickets_deleted' => DB::table('tickets')->where('tenant_code', $tenantCode)->delete(), 'purchase_changes_deleted' => DB::table('value_changes') ->where('tenant_code', $tenantCode) @@ -67,6 +74,7 @@ class TenantTransactionResetService ->update([ 'real_stock' => DB::raw('real_stock + sold_units - refunded_units'), 'reserved_stock' => 0, + 'entry_reserved_stock' => 0, 'sold_units' => 0, 'refunded_units' => 0, ]); diff --git a/app/Domains/Ticketing/Desfile/Controllers/EntryReservationController.php b/app/Domains/Ticketing/Desfile/Controllers/EntryReservationController.php new file mode 100644 index 00000000..f9e1cc2b --- /dev/null +++ b/app/Domains/Ticketing/Desfile/Controllers/EntryReservationController.php @@ -0,0 +1,85 @@ +reservations($request->user(), $request->validated()), + ); + } + + public function downloadPdf( + ExportEntryReservationsRequest $request, + EntryReservationService $service, + EntryReservationPdfService $pdf, + ): Response { + $user = $request->user(); + + return $pdf->download( + $user->tenant()->firstOrFail(), + $service->reservationsForExport($user, $request->validated()), + $request->validated('timezone'), + ); + } + + public function downloadExcel( + ExportEntryReservationsRequest $request, + EntryReservationService $service, + EntryReservationExcelService $excel, + ): StreamedResponse { + $user = $request->user(); + + return $excel->download( + $user->tenant()->firstOrFail(), + $service->reservationsForExport($user, $request->validated()), + $request->validated('timezone'), + ); + } + + public function downloadTicketPdf( + IndexEntryReservationsRequest $request, + int $reservation, + EntryReservationService $service, + TicketPdfService $pdf, + ): Response { + $user = $request->user(); + + return $pdf->download( + $user->tenant()->firstOrFail(), + collect([$service->reservationTicket($user, $reservation)]), + ); + } + + public function store(StoreEntryReservationsRequest $request, EntryReservationService $service): AnonymousResourceCollection + { + return EntryReservationResource::collection($service->reserve( + $request->user(), $request->validated('idempotency_key'), $request->validated('rows'), + )); + } + + public function destroy( + IndexEntryReservationsRequest $request, + int $reservation, + EntryReservationService $service, + ): Response { + $service->cancel($request->user(), $reservation); + + return response()->noContent(); + } +} diff --git a/app/Domains/Ticketing/Desfile/Enums/EntryReservationPaymentType.php b/app/Domains/Ticketing/Desfile/Enums/EntryReservationPaymentType.php new file mode 100644 index 00000000..bec60e8d --- /dev/null +++ b/app/Domains/Ticketing/Desfile/Enums/EntryReservationPaymentType.php @@ -0,0 +1,29 @@ + 'Sin cargo', + self::Other => 'Otro método', + }; + } + + /** @return list */ + public static function options(): array + { + return array_map( + fn (self $type): array => [ + 'value' => $type->value, + 'label' => $type->label(), + ], + self::cases(), + ); + } +} diff --git a/app/Domains/Ticketing/Desfile/Models/EntryReservation.php b/app/Domains/Ticketing/Desfile/Models/EntryReservation.php new file mode 100644 index 00000000..a9c20498 --- /dev/null +++ b/app/Domains/Ticketing/Desfile/Models/EntryReservation.php @@ -0,0 +1,54 @@ + 'integer', + 'fecha_reserva' => 'datetime', + 'importe' => 'decimal:2', + 'tipo_pago' => EntryReservationPaymentType::class, + ]; + } + + /** @return BelongsTo */ + public function variant(): BelongsTo + { + return $this->belongsTo(Variant::class)->withTrashed(); + } + + public function ticket(): BelongsTo + { + return $this->belongsTo(Ticket::class); + } + + public function inventory(): BelongsTo + { + return $this->belongsTo(Inventory::class); + } +} diff --git a/app/Domains/Ticketing/Desfile/Requests/ExportEntryReservationsRequest.php b/app/Domains/Ticketing/Desfile/Requests/ExportEntryReservationsRequest.php new file mode 100644 index 00000000..45aa3cce --- /dev/null +++ b/app/Domains/Ticketing/Desfile/Requests/ExportEntryReservationsRequest.php @@ -0,0 +1,16 @@ + ['required', 'string', new ValidTimezone], + ]; + } +} diff --git a/app/Domains/Ticketing/Desfile/Requests/IndexEntryReservationsRequest.php b/app/Domains/Ticketing/Desfile/Requests/IndexEntryReservationsRequest.php new file mode 100644 index 00000000..e546136b --- /dev/null +++ b/app/Domains/Ticketing/Desfile/Requests/IndexEntryReservationsRequest.php @@ -0,0 +1,24 @@ +user()?->tenant_codigo === 'desfile_pura_tendencia'; + } + + public function rules(): array + { + return [ + 'tipo_pago' => ['sometimes', 'nullable', Rule::enum(EntryReservationPaymentType::class)], + 'page' => ['sometimes', 'integer', 'min:1'], + 'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'], + ]; + } +} diff --git a/app/Domains/Ticketing/Desfile/Requests/StoreEntryReservationsRequest.php b/app/Domains/Ticketing/Desfile/Requests/StoreEntryReservationsRequest.php new file mode 100644 index 00000000..d4799926 --- /dev/null +++ b/app/Domains/Ticketing/Desfile/Requests/StoreEntryReservationsRequest.php @@ -0,0 +1,26 @@ +user()?->tenant_codigo === 'desfile_pura_tendencia'; + } + + public function rules(): array + { + return [ + 'idempotency_key' => ['required', 'uuid'], + 'rows' => ['required', 'array', 'min:1', 'max:100'], + 'rows.*' => ['required', 'array:variant_id,tipo_pago'], + 'rows.*.variant_id' => ['required', 'integer', 'min:1', 'distinct'], + 'rows.*.tipo_pago' => ['required', Rule::enum(EntryReservationPaymentType::class)], + ]; + } +} diff --git a/app/Domains/Ticketing/Desfile/Resources/EntryReservationResource.php b/app/Domains/Ticketing/Desfile/Resources/EntryReservationResource.php new file mode 100644 index 00000000..9ef77d37 --- /dev/null +++ b/app/Domains/Ticketing/Desfile/Resources/EntryReservationResource.php @@ -0,0 +1,47 @@ +relationLoaded('variant') + ? $this->variant->selectionOptions() + : collect(); + + return [ + 'id' => $this->id, + 'variant_id' => $this->variant_id, + 'ticket_id' => $this->ticket_id, + 'fecha_reserva' => $this->fecha_reserva->toIso8601String(), + 'tipo_pago' => $this->tipo_pago->value, + 'tipo_pago_label' => $this->tipo_pago->label(), + 'importe' => $this->importe, + 'entrada' => [ + 'tipo' => $this->selectionLabel($selection->get('tipo')), + 'sector' => $this->selectionLabel($selection->get('sector')), + 'fila' => $this->selectionLabel($selection->get('fila')), + 'asiento' => $this->selectionLabel($selection->get('asiento')), + ], + ]; + } + + private function selectionLabel(mixed $selection): ?string + { + if (! is_array($selection)) { + return null; + } + + if (array_is_list($selection)) { + $labels = collect($selection)->pluck('label')->filter()->implode(', '); + + return $labels !== '' ? $labels : null; + } + + return isset($selection['label']) ? (string) $selection['label'] : null; + } +} diff --git a/app/Domains/Ticketing/Desfile/Services/EntryReservationExcelService.php b/app/Domains/Ticketing/Desfile/Services/EntryReservationExcelService.php new file mode 100644 index 00000000..fb213cbc --- /dev/null +++ b/app/Domains/Ticketing/Desfile/Services/EntryReservationExcelService.php @@ -0,0 +1,91 @@ + $reservations */ + public function download(Tenant $tenant, Collection $reservations, string $timeZone): StreamedResponse + { + $generatedAt = now(); + $rows = $this->report->rows($reservations); + $spreadsheet = new Spreadsheet; + $spreadsheet->getProperties() + ->setCreator('Shopit') + ->setTitle('Reservas de entradas') + ->setSubject($tenant->nombre); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->setTitle('Reservas'); + $sheet->fromArray([ + 'Tipo', + 'Sector', + 'Fila', + 'Asiento', + 'ID', + 'Fecha', + 'Importe', + 'Pago', + ], null, 'A1'); + + foreach ($rows as $index => $reservation) { + $row = $index + 2; + foreach (['A' => 'tipo', 'B' => 'sector', 'C' => 'fila', 'D' => 'asiento'] as $column => $key) { + $sheet->setCellValueExplicit("{$column}{$row}", $reservation[$key], DataType::TYPE_STRING); + } + $sheet->setCellValueExplicit( + "E{$row}", + $reservation['ticket_id'] === null ? '-' : (string) $reservation['ticket_id'], + DataType::TYPE_STRING, + ); + $sheet->setCellValue( + "F{$row}", + Date::dateTimeToExcel($reservation['fecha_reserva']->copy()->timezone($timeZone)), + ); + if ($reservation['importe'] !== null) { + $sheet->setCellValue("G{$row}", (float) $reservation['importe']); + } + $sheet->setCellValueExplicit("H{$row}", $reservation['pago'], DataType::TYPE_STRING); + } + + $lastRow = max(2, $rows->count() + 1); + $sheet->getStyle("F2:F{$lastRow}")->getNumberFormat()->setFormatCode('dd/mm/yyyy hh:mm'); + $sheet->getStyle("G2:G{$lastRow}")->getNumberFormat()->setFormatCode('$ #,##0.00'); + $sheet->getStyle('A1:H1')->applyFromArray([ + 'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']], + 'fill' => [ + 'fillType' => Fill::FILL_SOLID, + 'startColor' => ['rgb' => '26382E'], + ], + 'alignment' => ['vertical' => Alignment::VERTICAL_CENTER], + ]); + $sheet->getRowDimension(1)->setRowHeight(24); + $sheet->freezePane('A2'); + $sheet->setAutoFilter("A1:H{$lastRow}"); + foreach (['A' => 20, 'B' => 22, 'C' => 12, 'D' => 12, 'E' => 16, 'F' => 20, 'G' => 16, 'H' => 18] as $column => $width) { + $sheet->getColumnDimension($column)->setWidth($width); + } + + $filename = 'reservas_entradas_'.$tenant->codigo.'_' + .$generatedAt->copy()->timezone($timeZone)->format('Ymd_His').'.xlsx'; + + return response()->streamDownload(function () use ($spreadsheet): void { + (new Xlsx($spreadsheet))->save('php://output'); + $spreadsheet->disconnectWorksheets(); + }, $filename, [ + 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ]); + } +} diff --git a/app/Domains/Ticketing/Desfile/Services/EntryReservationPdfService.php b/app/Domains/Ticketing/Desfile/Services/EntryReservationPdfService.php new file mode 100644 index 00000000..4df1da4c --- /dev/null +++ b/app/Domains/Ticketing/Desfile/Services/EntryReservationPdfService.php @@ -0,0 +1,51 @@ + $reservations */ + public function download(Tenant $tenant, Collection $reservations, string $timeZone): Response + { + $generatedAt = now(); + $rows = $this->report->rows($reservations); + $pdf = Pdf::loadView('pdf.adminapp.desfile-entry-reservations', [ + 'tenant' => $tenant, + 'reservations' => $rows, + 'generatedAt' => $generatedAt, + 'timeZone' => $timeZone, + ])->setPaper('a4', 'landscape'); + + $this->addPageNumbers($pdf); + + return $pdf->download( + 'reservas_entradas_'.$tenant->codigo.'_' + .$generatedAt->copy()->timezone($timeZone)->format('Ymd_His').'.pdf' + ); + } + + private function addPageNumbers(DomPdf $pdf): void + { + $pdf->render(); + $domPdf = $pdf->getDomPDF(); + $font = $domPdf->getFontMetrics()->getFont('DejaVu Sans'); + + $domPdf->getCanvas()->page_text( + 385, + 575, + 'Página {PAGE_NUM} de {PAGE_COUNT}', + $font, + 7, + [0.48, 0.52, 0.49], + ); + } +} diff --git a/app/Domains/Ticketing/Desfile/Services/EntryReservationReportService.php b/app/Domains/Ticketing/Desfile/Services/EntryReservationReportService.php new file mode 100644 index 00000000..e401c5a2 --- /dev/null +++ b/app/Domains/Ticketing/Desfile/Services/EntryReservationReportService.php @@ -0,0 +1,44 @@ + $reservations + * @return Collection> + */ + public function rows(Collection $reservations): Collection + { + return $reservations->values()->map(function (EntryReservation $reservation): array { + $selection = $reservation->variant->selectionOptions(); + + return [ + 'tipo' => $this->selectionLabel($selection->get('tipo')), + 'sector' => $this->selectionLabel($selection->get('sector')), + 'fila' => $this->selectionLabel($selection->get('fila')), + 'asiento' => $this->selectionLabel($selection->get('asiento')), + 'ticket_id' => $reservation->ticket_id, + 'fecha_reserva' => $reservation->fecha_reserva, + 'importe' => $reservation->importe, + 'pago' => $reservation->tipo_pago->label(), + ]; + }); + } + + private function selectionLabel(mixed $selection): string + { + if (! is_array($selection)) { + return '-'; + } + + if (array_is_list($selection)) { + return collect($selection)->pluck('label')->filter()->implode(', ') ?: '-'; + } + + return isset($selection['label']) ? (string) $selection['label'] : '-'; + } +} diff --git a/app/Domains/Ticketing/Desfile/Services/EntryReservationService.php b/app/Domains/Ticketing/Desfile/Services/EntryReservationService.php new file mode 100644 index 00000000..e8cb948d --- /dev/null +++ b/app/Domains/Ticketing/Desfile/Services/EntryReservationService.php @@ -0,0 +1,240 @@ + + */ + public function reservations(User $user, array $filters = []): LengthAwarePaginator + { + abort_unless($user->tenant_codigo === 'desfile_pura_tendencia', 403); + + return $this->reservationsQuery($user, $filters) + ->paginate( + perPage: $filters['per_page'] ?? 15, + pageName: 'page', + page: $filters['page'] ?? 1, + ) + ->withQueryString(); + } + + /** + * @param array{tipo_pago?: string|null} $filters + * @return Collection + */ + public function reservationsForExport(User $user, array $filters = []): Collection + { + abort_unless($user->tenant_codigo === 'desfile_pura_tendencia', 403); + + return $this->reservationsQuery($user, $filters)->get(); + } + + public function reservationTicket(User $user, int $reservationId): Ticket + { + abort_unless($user->tenant_codigo === 'desfile_pura_tendencia', 403); + + $reservation = EntryReservation::query() + ->whereKey($reservationId) + ->whereNotNull('ticket_id') + ->whereHas('variant.catalogItem', fn (Builder $query): Builder => $query + ->where('tenant_code', $user->tenant_codigo) + ->where('slug', 'entrada')) + ->with(['ticket' => fn ($query) => $query->with([ + ...TicketValidityResolver::RELATIONS, + ...TicketPresentationResolver::RELATIONS, + ])]) + ->firstOrFail(); + + return $reservation->ticket; + } + + public function cancel(User $user, int $reservationId): void + { + abort_unless($user->tenant_codigo === 'desfile_pura_tendencia', 403); + + DB::transaction(function () use ($user, $reservationId): void { + $reservation = EntryReservation::query() + ->whereKey($reservationId) + ->whereHas('variant.catalogItem', fn (Builder $query): Builder => $query + ->where('tenant_code', $user->tenant_codigo) + ->where('slug', 'entrada')) + ->lockForUpdate() + ->firstOrFail(); + + if ($reservation->ticket_id !== null) { + $ticket = Ticket::query()->lockForUpdate()->findOrFail($reservation->ticket_id); + + if (! $ticket->can_cancel()) { + throw ValidationException::withMessages([ + 'status' => 'El ticket debe estar activo para poder cancelar la reserva.', + ]); + } + + $ticket->markAsCancelled(); + $ticket->save(); + } + + if ($reservation->inventory_id !== null) { + $inventory = Inventory::query()->lockForUpdate()->findOrFail($reservation->inventory_id); + $inventory->releaseEntry(1); + } + + $reservation->delete(); + }, 3); + } + + /** @param array{tipo_pago?: string|null} $filters */ + private function reservationsQuery(User $user, array $filters = []): Builder + { + return EntryReservation::query() + ->whereHas('variant.catalogItem', fn (Builder $query): Builder => $query + ->where('tenant_code', $user->tenant_codigo) + ->where('slug', 'entrada')) + ->when( + $filters['tipo_pago'] ?? null, + fn (Builder $query, string $paymentType): Builder => $query->where('tipo_pago', $paymentType), + ) + ->with([ + 'variant.catalogItem.itemAttributes.attribute.options', + 'variant.definitions.itemAttribute.attribute.options', + 'variant.eventDates', + 'variant.eventDate', + ]) + ->orderByDesc('fecha_reserva') + ->orderByDesc('id'); + } + + /** @param list $rows */ + public function reserve(User $user, string $key, array $rows): Collection + { + abort_unless($user->tenant_codigo === 'desfile_pura_tendencia', 403); + $normalized = collect($rows)->map(fn (array $row): array => [ + 'variant_id' => (int) $row['variant_id'], 'tipo_pago' => $row['tipo_pago'], + ])->sortBy('variant_id')->values()->all(); + $hash = hash('sha256', json_encode($normalized, JSON_THROW_ON_ERROR)); + + return DB::transaction(function () use ($user, $key, $rows, $hash): Collection { + // Serialize retries by the same administrator, including the first insert. + User::query()->whereKey($user->id)->lockForUpdate()->firstOrFail(); + $batch = DB::table('desfile_reservation_batches') + ->where('user_id', $user->id)->where('idempotency_key', $key)->lockForUpdate()->first(); + if ($batch !== null) { + abort_unless($batch->tenant_code === $user->tenant_codigo && hash_equals($batch->request_hash, $hash), 409, + 'La clave de envío ya fue utilizada con otras entradas.'); + + return EntryReservation::query()->where('batch_id', $batch->id)->with('ticket')->orderBy('id')->get(); + } + + $tenant = $user->tenant()->firstOrFail(); + $entry = CatalogItem::query()->forTenantCatalog($tenant)->where('slug', 'entrada') + ->lockForUpdate()->firstOrFail(); + $ids = array_column($rows, 'variant_id'); + $variants = $entry->variants()->whereKey($ids)->orderBy('id')->lockForUpdate()->get(); + $inventories = Inventory::query()->whereKey($variants->pluck('inventory_id')->filter()->unique()) + ->orderBy('id')->lockForUpdate()->get()->keyBy('id'); + $variants->load([ + 'eventDates', 'eventDate', + 'desfileEntryReservations' => fn ($query) => $query->lockForUpdate(), + ]); + foreach ($variants as $variant) { + $variant->setRelation('inventory', $inventories->get($variant->inventory_id)); + } + $entry->setRelation('variants', $variants); + $available = $entry->visibleVariants()->keyBy('id'); + $requirements = []; + $errors = []; + foreach ($rows as $index => $row) { + $variant = $available->get($row['variant_id']); + if ($variant === null || $variant->inventory === null) { + $errors["rows.{$index}.variant_id"] = $this->entryLabel($variants->firstWhere('id', $row['variant_id']), $index).': la entrada ya no está disponible.'; + + continue; + } + $requirements[$variant->inventory_id] = ($requirements[$variant->inventory_id] ?? 0) + 1; + } + if ($errors !== []) { + throw ValidationException::withMessages($errors); + } + $tracked = $entry->inventory_policy !== InventoryPolicy::Unlimited; + foreach ($requirements as $inventoryId => $quantity) { + if ($tracked && $inventories[$inventoryId]->availableStock() < $quantity) { + foreach ($rows as $index => $row) { + $variant = $available[$row['variant_id']]; + if ($variant->inventory_id === $inventoryId) { + $errors["rows.{$index}.variant_id"] = $this->entryLabel($variant, $index).': no hay stock suficiente para reservar las entradas seleccionadas.'; + } + } + } + } + if ($errors !== []) { + throw ValidationException::withMessages($errors); + } + + $batchId = DB::table('desfile_reservation_batches')->insertGetId([ + 'user_id' => $user->id, 'tenant_code' => $tenant->codigo, + 'idempotency_key' => $key, 'request_hash' => $hash, + 'created_at' => now(), 'updated_at' => now(), + ]); + foreach ($requirements as $inventoryId => $quantity) { + $inventories[$inventoryId]->reserveEntry($quantity, $tracked); + } + $reservations = collect(); + foreach ($rows as $index => $row) { + $variant = $available[$row['variant_id']]; + $payment = EntryReservationPaymentType::from($row['tipo_pago']); + try { + $ticket = $this->tickets->generate($entry, $user, 1, $variant->id)->sole(); + } catch (TicketGenerationException $exception) { + throw ValidationException::withMessages([ + "rows.{$index}.variant_id" => $this->entryLabel($variant, $index).': no se pudo emitir el ticket. '.$exception->getMessage(), + ]); + } + $reservation = EntryReservation::query()->create([ + 'batch_id' => $batchId, 'ticket_id' => $ticket->id, + 'variant_id' => $variant->id, 'inventory_id' => $variant->inventory_id, + 'fecha_reserva' => now(), 'tipo_pago' => $payment, + 'importe' => $payment === EntryReservationPaymentType::Free ? 0 : $variant->getPrice(), + ]); + $reservations->push($reservation->setRelation('ticket', $ticket)); + } + + return $reservations; + }, 3); + } + + private function entryLabel(?Variant $variant, int $index): string + { + if ($variant === null) { + return 'Entrada '.($index + 1); + } + + $values = $variant->selectionValues(); + + return collect(['tipo' => 'Tipo', 'sector' => 'Sector', 'fila' => 'Fila', 'asiento' => 'Asiento']) + ->map(fn (string $label, string $key): string => $label.': '.($values->get($key) ?? 'sin especificar')) + ->implode(', '); + } +} diff --git a/app/Domains/Ticketing/Desfile/Services/EntryService.php b/app/Domains/Ticketing/Desfile/Services/EntryService.php index 12661777..5c8d389e 100644 --- a/app/Domains/Ticketing/Desfile/Services/EntryService.php +++ b/app/Domains/Ticketing/Desfile/Services/EntryService.php @@ -2,13 +2,13 @@ namespace App\Domains\Ticketing\Desfile\Services; -use App\Shared\Attachable\Models\Attachment; -use App\Shared\Attachable\Services\AttachmentService; use App\Domains\Commerce\Catalog\Models\CatalogItem; use App\Domains\Commerce\Catalog\Models\Inventory; use App\Domains\Commerce\Catalog\Models\ItemAttribute; use App\Domains\Commerce\Catalog\Models\Variant; use App\Domains\Core\Tenant\Models\Tenant; +use App\Shared\Attachable\Models\Attachment; +use App\Shared\Attachable\Services\AttachmentService; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\UploadedFile; use Illuminate\Support\Collection; @@ -254,7 +254,7 @@ class EntryService { $inventory = $variant->inventory; - if (($inventory?->reserved_stock ?? 0) > 0 || ($inventory?->sold_units ?? 0) > 0) { + if (($inventory?->reserved_stock ?? 0) > 0 || ($inventory?->entry_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.', diff --git a/app/Domains/Ticketing/Desfile/Services/InvitationPurchaseProvisioner.php b/app/Domains/Ticketing/Desfile/Services/InvitationPurchaseProvisioner.php index 382fe2d6..a2be68ea 100644 --- a/app/Domains/Ticketing/Desfile/Services/InvitationPurchaseProvisioner.php +++ b/app/Domains/Ticketing/Desfile/Services/InvitationPurchaseProvisioner.php @@ -393,7 +393,7 @@ class InvitationPurchaseProvisioner $inventory = DB::table('inventories')->where('id', $variant->inventory_id)->lockForUpdate()->first(); - if ($inventory === null || $inventory->real_stock < 1 || $inventory->reserved_stock > 0) { + if ($inventory === null || $inventory->real_stock < 1 || $inventory->reserved_stock > 0 || ($inventory->entry_reserved_stock ?? 0) > 0) { throw new RuntimeException("El asiento {$variant->descripcion} ya no está disponible."); } diff --git a/app/Domains/Ticketing/Desfile/routes/api.php b/app/Domains/Ticketing/Desfile/routes/api.php index 9d2429b6..f588ecdf 100644 --- a/app/Domains/Ticketing/Desfile/routes/api.php +++ b/app/Domains/Ticketing/Desfile/routes/api.php @@ -1,8 +1,30 @@ middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas']) + ->name('adminapp.desfile.entry-reservations.index'); +Route::get('v1/adminapp/tenant/desfile/entry-reservations/pdf', [EntryReservationController::class, 'downloadPdf']) + ->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas']) + ->name('adminapp.desfile.entry-reservations.pdf'); +Route::get('v1/adminapp/tenant/desfile/entry-reservations/excel', [EntryReservationController::class, 'downloadExcel']) + ->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas']) + ->name('adminapp.desfile.entry-reservations.excel'); +Route::get('v1/adminapp/tenant/desfile/entry-reservations/{reservation}/ticket/pdf', [EntryReservationController::class, 'downloadTicketPdf']) + ->whereNumber('reservation') + ->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas']) + ->name('adminapp.desfile.entry-reservations.ticket.pdf'); +Route::post('v1/adminapp/tenant/desfile/entry-reservations', [EntryReservationController::class, 'store']) + ->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas']) + ->name('adminapp.desfile.entry-reservations.store'); +Route::delete('v1/adminapp/tenant/desfile/entry-reservations/{reservation}', [EntryReservationController::class, 'destroy']) + ->whereNumber('reservation') + ->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas']) + ->name('adminapp.desfile.entry-reservations.destroy'); + Route::prefix('v1/adminapp/tenant/desfile') ->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.entradas']) ->group(function (): void { diff --git a/app/Shared/Forms/Controllers/AdminApp/DesfileEntryReservationFormController.php b/app/Shared/Forms/Controllers/AdminApp/DesfileEntryReservationFormController.php new file mode 100644 index 00000000..425fa297 --- /dev/null +++ b/app/Shared/Forms/Controllers/AdminApp/DesfileEntryReservationFormController.php @@ -0,0 +1,22 @@ +formService->get($request->user()->tenant()->firstOrFail()), + ); + } +} diff --git a/app/Shared/Forms/Resources/DesfileEntryReservationFormResource.php b/app/Shared/Forms/Resources/DesfileEntryReservationFormResource.php new file mode 100644 index 00000000..11ef8441 --- /dev/null +++ b/app/Shared/Forms/Resources/DesfileEntryReservationFormResource.php @@ -0,0 +1,19 @@ + */ + public function toArray(Request $request): array + { + return [ + 'payment_types' => $this->resource['payment_types'], + 'fields' => $this->resource['fields'], + 'variants' => $this->resource['variants'], + ]; + } +} diff --git a/app/Shared/Forms/Services/DesfileEntryReservationFormService.php b/app/Shared/Forms/Services/DesfileEntryReservationFormService.php new file mode 100644 index 00000000..3c37bde4 --- /dev/null +++ b/app/Shared/Forms/Services/DesfileEntryReservationFormService.php @@ -0,0 +1,49 @@ + */ + public function get(Tenant $tenant): array + { + $entry = CatalogItem::query()->forTenantCatalog($tenant)->where('slug', 'entrada') + ->with([ + 'variants' => fn ($query) => $query->orderBy('id'), + 'variants.inventory', + 'variants.definitions.itemAttribute.attribute', + 'variants.eventDates', + 'variants.eventDate', + ])->first(); + $variants = $entry?->visibleVariants() + ->map(function (Variant $variant): array { + $values = $variant->selectionValues(); + + return [ + 'id' => $variant->id, + 'tipo' => (string) $values->get('tipo'), + 'sector' => (string) $values->get('sector'), + 'fila' => (string) $values->get('fila'), + 'asiento' => (string) $values->get('asiento'), + 'price' => $variant->getPrice(), + ]; + })->values() ?? collect(); + + return [ + 'payment_types' => EntryReservationPaymentType::options(), + 'fields' => collect(['tipo' => 'Tipo', 'sector' => 'Sector', 'fila' => 'Fila', 'asiento' => 'Asiento']) + ->map(fn (string $label, string $key): array => [ + 'key' => $key, + 'label' => $label, + 'options' => $variants->pluck($key)->unique()->sort(SORT_NATURAL)->values() + ->map(fn (string $value): array => ['value' => $value, 'label' => $value])->all(), + ])->values()->all(), + 'variants' => $variants->all(), + ]; + } +} diff --git a/app/Shared/Forms/routes/adminapp.php b/app/Shared/Forms/routes/adminapp.php index 5afd6258..6517d639 100644 --- a/app/Shared/Forms/routes/adminapp.php +++ b/app/Shared/Forms/routes/adminapp.php @@ -1,5 +1,6 @@ middleware(['auth:sanctum', 'adminapp.tenant']) ->group(function (): void { Route::get('event', EventFormController::class); + Route::get( + 'desfile/entry-reservation', + DesfileEntryReservationFormController::class + )->middleware('tenant.menu:adminapp.desfile.reservas') + ->name('adminapp.forms.desfile.entry-reservation'); Route::get('sale', SaleFormController::class); Route::get('staff', StaffFormController::class); Route::get('tickets-filter', TicketFilterFormController::class) diff --git a/database/migrations/2026_09_23_000000_add_desfile_ticket_reservation_adminapp_menu.php b/database/migrations/2026_09_23_000000_add_desfile_ticket_reservation_adminapp_menu.php new file mode 100644 index 00000000..309ebcc8 --- /dev/null +++ b/database/migrations/2026_09_23_000000_add_desfile_ticket_reservation_adminapp_menu.php @@ -0,0 +1,82 @@ +where('code', 'main.adminapp')->exists()) { + // Reference data is added by seeders on fresh installations. + return; + } + + $now = now(); + + DB::transaction(function () use ($now): void { + DB::table('menues')->updateOrInsert( + ['code' => self::MENU_CODE], + [ + 'label' => 'Reserva de Tickets', + 'parent_menu_code' => 'main.adminapp', + 'content_type' => 'dynamic', + 'static_content_schema' => null, + 'route' => '/admin/desfile/reservas', + 'created_at' => $now, + 'updated_at' => $now, + ], + ); + + DB::table('tenants_menues') + ->where('menu_code', self::MENU_CODE) + ->where('tenant_code', '!=', self::TENANT_CODE) + ->delete(); + + if (DB::table('tenants')->where('codigo', self::TENANT_CODE)->exists()) { + DB::table('tenants_menues')->updateOrInsert( + [ + 'tenant_code' => self::TENANT_CODE, + 'menu_code' => self::MENU_CODE, + ], + [ + 'static_content' => null, + 'created_at' => $now, + 'updated_at' => $now, + ], + ); + } + + DB::table('roles') + ->whereIn('codigo', ['admin', 'adminapp']) + ->pluck('codigo') + ->each(function (string $roleCode): void { + DB::table('roles_menues')->updateOrInsert([ + 'rol_codigo' => $roleCode, + 'menu_codigo' => self::MENU_CODE, + ]); + }); + }); + } + + public function down(): void + { + DB::transaction(function (): void { + DB::table('tenants_menues') + ->where('menu_code', self::MENU_CODE) + ->delete(); + + DB::table('roles_menues') + ->where('menu_codigo', self::MENU_CODE) + ->delete(); + + DB::table('menues') + ->where('code', self::MENU_CODE) + ->delete(); + }); + } +}; diff --git a/database/migrations/2026_09_23_010000_create_desfile_entry_reservations_table.php b/database/migrations/2026_09_23_010000_create_desfile_entry_reservations_table.php new file mode 100644 index 00000000..c1679190 --- /dev/null +++ b/database/migrations/2026_09_23_010000_create_desfile_entry_reservations_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('variant_id') + ->constrained('variantes') + ->restrictOnDelete(); + $table->timestamp('fecha_reserva'); + $table->decimal('importe', 10, 2)->nullable(); + $table->string('tipo_pago', 24); + $table->timestamps(); + + $table->index(['fecha_reserva', 'tipo_pago']); + }); + } + + public function down(): void + { + Schema::dropIfExists('desfile_entry_reservations'); + } +}; diff --git a/database/migrations/2026_09_24_000000_add_administrative_entry_reservation_stock.php b/database/migrations/2026_09_24_000000_add_administrative_entry_reservation_stock.php new file mode 100644 index 00000000..f1f038be --- /dev/null +++ b/database/migrations/2026_09_24_000000_add_administrative_entry_reservation_stock.php @@ -0,0 +1,43 @@ +unsignedInteger('entry_reserved_stock')->default(0); + }); + Schema::create('desfile_reservation_batches', function (Blueprint $table): void { + $table->id(); + $table->foreignId('user_id')->constrained('users')->restrictOnDelete(); + $table->string('tenant_code'); + $table->uuid('idempotency_key'); + $table->string('request_hash', 64); + $table->timestamps(); + $table->unique(['user_id', 'idempotency_key']); + }); + Schema::table('desfile_entry_reservations', function (Blueprint $table): void { + $table->foreignId('batch_id')->nullable()->constrained('desfile_reservation_batches')->restrictOnDelete(); + $table->foreignId('ticket_id')->nullable()->unique()->constrained('tickets')->restrictOnDelete(); + $table->foreignId('inventory_id')->nullable()->constrained('inventories')->restrictOnDelete(); + }); + // Legacy reservations remain excluded by visibleVariants(). Their past stock + // movements cannot be inferred safely; only new reservations use this counter. + } + + public function down(): void + { + Schema::table('desfile_entry_reservations', function (Blueprint $table): void { + $table->dropConstrainedForeignId('batch_id'); + $table->dropUnique(['ticket_id']); + $table->dropConstrainedForeignId('ticket_id'); + $table->dropConstrainedForeignId('inventory_id'); + }); + Schema::dropIfExists('desfile_reservation_batches'); + Schema::table('inventories', fn (Blueprint $table) => $table->dropColumn('entry_reserved_stock')); + } +}; diff --git a/database/migrations/2026_09_24_010000_add_soft_deletes_to_desfile_entry_reservations_table.php b/database/migrations/2026_09_24_010000_add_soft_deletes_to_desfile_entry_reservations_table.php new file mode 100644 index 00000000..e3360fa8 --- /dev/null +++ b/database/migrations/2026_09_24_010000_add_soft_deletes_to_desfile_entry_reservations_table.php @@ -0,0 +1,22 @@ +softDeletes(); + }); + } + + public function down(): void + { + Schema::table('desfile_entry_reservations', function (Blueprint $table): void { + $table->dropSoftDeletes(); + }); + } +}; diff --git a/database/seeders/MenuSeeder.php b/database/seeders/MenuSeeder.php index f698f67a..183ec639 100644 --- a/database/seeders/MenuSeeder.php +++ b/database/seeders/MenuSeeder.php @@ -130,6 +130,12 @@ class MenuSeeder extends Seeder 'parent_menu_code' => 'main.adminapp', 'route' => '/admin/desfile/entradas', ], + [ + 'code' => 'adminapp.desfile.reservas', + 'label' => 'Reserva de Tickets', + 'parent_menu_code' => 'main.adminapp', + 'route' => '/admin/desfile/reservas', + ], [ 'code' => 'account', 'label' => 'Mi cuenta', @@ -306,6 +312,7 @@ class MenuSeeder extends Seeder ]; $desfileMenuCodes = [ 'adminapp.desfile.entradas', + 'adminapp.desfile.reservas', ]; $onTicketMenuCodes = [ 'event.index', diff --git a/resources/views/pdf/adminapp/desfile-entry-reservations.blade.php b/resources/views/pdf/adminapp/desfile-entry-reservations.blade.php new file mode 100644 index 00000000..65387e4c --- /dev/null +++ b/resources/views/pdf/adminapp/desfile-entry-reservations.blade.php @@ -0,0 +1,61 @@ + + + + + + + +

Reservas de entradas

+

{{ $tenant->nombre }} · Generado el {{ $generatedAt->copy()->timezone($timeZone)->format('d/m/Y H:i') }}

+ +
+ Reservas incluidas: {{ $reservations->count() }} +
+ + + + + + + + + + + + + + + + @forelse ($reservations as $reservation) + + + + + + + + + + + @empty + + @endforelse + +
TipoSectorFilaAsientoIDFechaImportePago
{{ $reservation['tipo'] }}{{ $reservation['sector'] }}{{ $reservation['fila'] }}{{ $reservation['asiento'] }}{{ $reservation['ticket_id'] ?? '-' }}{{ $reservation['fecha_reserva']->copy()->timezone($timeZone)->format('d/m/Y H:i') }}{{ $reservation['importe'] === null ? '-' : '$'.number_format((float) $reservation['importe'], 2, ',', '.') }}{{ $reservation['pago'] }}
No hay reservas para los criterios seleccionados.
+ + diff --git a/tests/Feature/Desfile/EntryReservationSchemaTest.php b/tests/Feature/Desfile/EntryReservationSchemaTest.php new file mode 100644 index 00000000..7cc68d82 --- /dev/null +++ b/tests/Feature/Desfile/EntryReservationSchemaTest.php @@ -0,0 +1,23 @@ +assertTrue(Schema::hasColumns('desfile_entry_reservations', [ + 'id', + 'variant_id', + 'fecha_reserva', + 'importe', + 'tipo_pago', + ])); + } +} diff --git a/tests/Feature/Desfile/EntryReservationServiceTest.php b/tests/Feature/Desfile/EntryReservationServiceTest.php new file mode 100644 index 00000000..b3c54b19 --- /dev/null +++ b/tests/Feature/Desfile/EntryReservationServiceTest.php @@ -0,0 +1,462 @@ +app->register(DomPdfServiceProvider::class); + // Minimal domain schema isolates this workflow from unrelated legacy migrations. + Schema::create('users', function (Blueprint $table): void { + $table->id(); + $table->string('tenant_codigo'); + $table->softDeletes(); + }); + Schema::create('tenants', function (Blueprint $table): void { + $table->id(); + $table->string('codigo')->unique(); + $table->unsignedBigInteger('active_event_id')->nullable(); + }); + Schema::create('catalog_items', function (Blueprint $table): void { + $table->id(); + $table->string('tenant_code'); + $table->string('slug'); + $table->string('inventory_policy'); + $table->decimal('precio', 10, 2); + $table->timestamp('sales_end_at')->nullable(); + $table->softDeletes(); + $table->boolean('has_tickets')->default(true); + $table->unsignedBigInteger('event_id')->nullable(); + }); + Schema::create('inventories', function (Blueprint $table): void { + $table->id(); + foreach (['real_stock', 'reserved_stock', 'sold_units', 'refunded_units'] as $column) { + $table->integer($column)->default(0); + } + }); + Schema::create('variantes', function (Blueprint $table): void { + $table->id(); + $table->foreignId('catalog_item_id'); + $table->foreignId('inventory_id'); + $table->decimal('precio', 10, 2); + $table->unsignedBigInteger('event_date_id')->nullable(); + $table->unsignedBigInteger('replaced_by_variant_id')->nullable(); + $table->timestamp('sales_disabled_at')->nullable(); + $table->softDeletes(); + }); + Schema::create('event_dates', function (Blueprint $table): void { + $table->id(); + $table->date('date'); + $table->time('time_start')->nullable(); + }); + Schema::create('attribute', function (Blueprint $table): void { + $table->id(); + $table->string('codigo'); + $table->string('type')->default('string'); + }); + Schema::create('item_attributes', function (Blueprint $table): void { + $table->id(); + $table->foreignId('catalog_item_id'); + $table->foreignId('attribute_id'); + $table->boolean('allow_multi_select')->default(false); + $table->integer('sort_order')->default(0); + }); + Schema::create('attribute_options', function (Blueprint $table): void { + $table->id(); + $table->foreignId('attribute_id'); + $table->string('value'); + $table->string('label'); + $table->integer('sort_order')->default(0); + }); + Schema::create('variant_values', function (Blueprint $table): void { + $table->id(); + $table->foreignId('variant_id'); + $table->foreignId('item_attribute_id'); + $table->string('value'); + }); + Schema::create('variant_event_dates', function (Blueprint $table): void { + $table->foreignId('variant_id'); + $table->foreignId('event_date_id'); + }); + Schema::create('tickets', function (Blueprint $table): void { + $table->id(); + $table->foreignId('user_id'); + $table->foreignId('source_variant_id'); + $table->string('tenant_code')->nullable(); + $table->uuid('ticket')->nullable()->unique(); + $table->unsignedBigInteger('source_catalog_item_id')->nullable(); + $table->unsignedBigInteger('source_purchase_item_id')->nullable(); + $table->unsignedBigInteger('event_id')->nullable(); + $table->timestamp('used_at')->nullable(); + $table->timestamp('disabled_at')->nullable(); + $table->timestamp('cancelled_at')->nullable(); + $table->timestamp('refunded_at')->nullable(); + }); + Schema::create('value_changes', function (Blueprint $table): void { + $table->id(); + $table->morphs('trackable'); + $table->string('tenant_code'); + $table->string('attribute'); + $table->text('old_value')->nullable(); + $table->text('new_value')->nullable(); + $table->timestamp('changed_at'); + $table->string('actor_type'); + $table->foreignId('user_id')->nullable(); + }); + (require database_path('migrations/2026_09_23_010000_create_desfile_entry_reservations_table.php'))->up(); + (require database_path('migrations/2026_09_24_000000_add_administrative_entry_reservation_stock.php'))->up(); + (require database_path('migrations/2026_09_24_010000_add_soft_deletes_to_desfile_entry_reservations_table.php'))->up(); + DB::table('tenants')->insert(['codigo' => 'desfile_pura_tendencia']); + DB::table('users')->insert(['id' => 1, 'tenant_codigo' => 'desfile_pura_tendencia']); + DB::table('catalog_items')->insert([ + 'id' => 1, 'tenant_code' => 'desfile_pura_tendencia', 'slug' => 'entrada', + 'inventory_policy' => 'tracked', 'precio' => 100, + ]); + foreach ([1, 2] as $id) { + DB::table('inventories')->insert(['id' => $id, 'real_stock' => 1]); + DB::table('variantes')->insert(['id' => $id, 'catalog_item_id' => 1, 'inventory_id' => $id, 'precio' => 250]); + } + foreach (['tipo' => 'NORMAL', 'sector' => 'A', 'fila' => '3', 'asiento' => '17'] as $code => $value) { + $attributeId = DB::table('attribute')->insertGetId(['codigo' => $code]); + $itemAttributeId = DB::table('item_attributes')->insertGetId([ + 'catalog_item_id' => 1, + 'attribute_id' => $attributeId, + ]); + DB::table('attribute_options')->insert([ + 'attribute_id' => $attributeId, + 'value' => $value, + 'label' => $value, + ]); + foreach ([1, 2] as $variantId) { + DB::table('variant_values')->insert([ + 'variant_id' => $variantId, 'item_attribute_id' => $itemAttributeId, 'value' => $value, + ]); + } + } + } + + private function service(bool $failSecond = false): EntryReservationService + { + $generator = Mockery::mock(TicketGeneratorService::class); + $generator->shouldReceive('generate')->andReturnUsing(function ($entry, $user, $quantity, $variantId) use ($failSecond) { + if ($failSecond && $variantId === 2) { + throw new \RuntimeException('Ticket generation failed'); + } + $this->assertSame(1, $quantity); + $id = DB::table('tickets')->insertGetId([ + 'user_id' => $user->id, + 'source_variant_id' => $variantId, + 'tenant_code' => $user->tenant_codigo, + ]); + + return collect([Ticket::query()->findOrFail($id)]); + }); + + return new EntryReservationService($generator); + } + + private function rows(): array + { + return [['variant_id' => 1, 'tipo_pago' => 'sin_cargo'], ['variant_id' => 2, 'tipo_pago' => 'otro_metodo']]; + } + + public function test_reserves_stock_calculates_prices_and_replays_without_duplicates(): void + { + $service = $this->service(); + $key = (string) Str::uuid(); + $user = User::query()->findOrFail(1); + $result = $service->reserve($user, $key, $this->rows()); + $this->assertSame(['0.00', '250.00'], $result->pluck('importe')->all()); + $replayed = $service->reserve($user, $key, $this->rows()); + $this->assertSame($result->pluck('id')->all(), $replayed->pluck('id')->all()); + $this->assertDatabaseCount('tickets', 2); + $this->assertDatabaseCount('desfile_entry_reservations', 2); + $this->assertDatabaseCount('desfile_reservation_batches', 1); + foreach (Inventory::all() as $inventory) { + $this->assertSame(1, $inventory->entry_reserved_stock); + $this->assertSame(1, $inventory->real_stock); + $this->assertSame(0, $inventory->reserved_stock); + $this->assertSame(0, $inventory->sold_units); + $this->assertSame(0, $inventory->availableStock()); + } + $this->assertDatabaseHas('tickets', ['user_id' => 1, 'source_variant_id' => 1]); + } + + public function test_conflicting_cart_stock_rejects_the_entire_batch(): void + { + DB::table('inventories')->where('id', 2)->update(['reserved_stock' => 1]); + try { + $this->service()->reserve(User::findOrFail(1), (string) Str::uuid(), $this->rows()); + $this->fail('Expected unavailable variant'); + } catch (ValidationException $error) { + $this->assertArrayHasKey('rows.1.variant_id', $error->errors()); + $this->assertStringContainsString('Tipo: NORMAL, Sector: A, Fila: 3, Asiento: 17', $error->errors()['rows.1.variant_id'][0]); + } + $this->assertDatabaseCount('tickets', 0); + $this->assertDatabaseCount('desfile_entry_reservations', 0); + $this->assertSame(0, (int) Inventory::sum('entry_reserved_stock')); + } + + public function test_ticket_failure_rolls_back_stock_tickets_and_idempotency_record(): void + { + try { + $this->service(true)->reserve(User::findOrFail(1), (string) Str::uuid(), $this->rows()); + $this->fail('Expected generation failure'); + } catch (\RuntimeException $error) { + $this->assertSame('Ticket generation failed', $error->getMessage()); + } + $this->assertDatabaseCount('tickets', 0); + $this->assertDatabaseCount('desfile_entry_reservations', 0); + $this->assertDatabaseCount('desfile_reservation_batches', 0); + $this->assertSame(0, (int) Inventory::sum('entry_reserved_stock')); + } + + public function test_new_attempt_cannot_reserve_an_already_reserved_variant(): void + { + $service = $this->service(); + $user = User::findOrFail(1); + $service->reserve($user, (string) Str::uuid(), $this->rows()); + $this->expectException(ValidationException::class); + $service->reserve($user, (string) Str::uuid(), $this->rows()); + } + + public function test_it_cancels_the_ticket_releases_stock_and_soft_deletes_the_reservation(): void + { + $service = $this->service(); + $user = User::findOrFail(1); + $reservation = $service->reserve($user, (string) Str::uuid(), [$this->rows()[0]])->sole(); + + $service->cancel($user, $reservation->id); + + $this->assertSoftDeleted('desfile_entry_reservations', ['id' => $reservation->id]); + $this->assertNotNull(Ticket::findOrFail($reservation->ticket_id)->cancelled_at); + $this->assertSame(0, Inventory::findOrFail(1)->entry_reserved_stock); + $this->assertSame(1, Inventory::findOrFail(1)->availableStock()); + $this->assertSame(0, $service->reservations($user)->total()); + $this->assertCount(0, $service->reservationsForExport($user)); + } + + public function test_it_does_not_release_stock_when_the_ticket_is_not_active(): void + { + $service = $this->service(); + $user = User::findOrFail(1); + $reservation = $service->reserve($user, (string) Str::uuid(), [$this->rows()[0]])->sole(); + Ticket::query()->whereKey($reservation->ticket_id)->update(['used_at' => now()]); + + try { + $service->cancel($user, $reservation->id); + $this->fail('Expected cancellation validation error'); + } catch (ValidationException $error) { + $this->assertArrayHasKey('status', $error->errors()); + } + + $this->assertDatabaseHas('desfile_entry_reservations', [ + 'id' => $reservation->id, + 'deleted_at' => null, + ]); + $this->assertSame(1, Inventory::findOrFail(1)->entry_reserved_stock); + } + + public function test_a_reused_key_with_different_rows_is_rejected(): void + { + $service = $this->service(); + $key = (string) Str::uuid(); + $user = User::findOrFail(1); + $service->reserve($user, $key, $this->rows()); + try { + $service->reserve($user, $key, [['variant_id' => 1, 'tipo_pago' => 'otro_metodo']]); + $this->fail('Expected conflict'); + } catch (HttpException $error) { + $this->assertSame(409, $error->getStatusCode()); + } + $this->assertDatabaseCount('tickets', 2); + } + + public function test_variants_outside_the_tenant_catalog_are_rejected(): void + { + DB::table('variantes')->where('id', 2)->update(['catalog_item_id' => 999]); + $this->expectException(ValidationException::class); + $this->service()->reserve(User::findOrFail(1), (string) Str::uuid(), $this->rows()); + } + + public function test_shared_inventory_is_checked_for_the_whole_batch(): void + { + DB::table('variantes')->where('id', 2)->update(['inventory_id' => 1]); + try { + $this->service()->reserve(User::findOrFail(1), (string) Str::uuid(), $this->rows()); + $this->fail('Expected insufficient shared stock'); + } catch (ValidationException $error) { + $this->assertSame(['rows.0.variant_id', 'rows.1.variant_id'], array_keys($error->errors())); + foreach ($error->errors() as $messages) { + $this->assertStringContainsString('Tipo: NORMAL, Sector: A, Fila: 3, Asiento: 17', $messages[0]); + } + } + $this->assertDatabaseCount('tickets', 0); + } + + public function test_cart_cannot_reserve_administratively_reserved_stock(): void + { + $this->service()->reserve(User::findOrFail(1), (string) Str::uuid(), $this->rows()); + $this->expectException(\InvalidArgumentException::class); + Inventory::findOrFail(1)->reserve(1, true); + } + + public function test_lists_paginated_reservations_filtered_by_payment_type(): void + { + $service = $this->service(); + $user = User::findOrFail(1); + $service->reserve($user, (string) Str::uuid(), $this->rows()); + + $reservations = $service->reservations($user, [ + 'tipo_pago' => 'otro_metodo', + 'page' => 1, + 'per_page' => 1, + ]); + + $this->assertSame(1, $reservations->total()); + $this->assertSame(1, $reservations->perPage()); + $reservation = $reservations->sole(); + $this->assertSame('otro_metodo', $reservation->tipo_pago->value); + $this->assertNotNull($reservation->ticket_id); + $this->assertSame('NORMAL', $reservation->variant->selectionValues()->get('tipo')); + + $payload = (new EntryReservationResource($reservation))->toArray(Request::create('/')); + $this->assertSame($reservation->id, $payload['id']); + $this->assertSame($reservation->ticket_id, $payload['ticket_id']); + $this->assertSame('NORMAL', $payload['entrada']['tipo']); + $this->assertSame('Otro método', $payload['tipo_pago_label']); + } + + public function test_request_rejects_duplicate_variants_and_client_amounts(): void + { + $rules = (new StoreEntryReservationsRequest)->rules(); + $validator = Validator::make([ + 'idempotency_key' => (string) Str::uuid(), + 'rows' => [ + ['variant_id' => 1, 'tipo_pago' => 'sin_cargo', 'importe' => 0], + ['variant_id' => 1, 'tipo_pago' => 'invalid'], + ], + ], $rules); + $this->assertTrue($validator->fails()); + $this->assertArrayHasKey('rows.0', $validator->errors()->toArray()); + $this->assertArrayHasKey('rows.1.variant_id', $validator->errors()->toArray()); + $this->assertArrayHasKey('rows.1.tipo_pago', $validator->errors()->toArray()); + } + + public function test_index_request_validates_payment_type_and_pagination(): void + { + $validator = Validator::make([ + 'tipo_pago' => 'invalid', + 'page' => 0, + 'per_page' => 101, + ], (new IndexEntryReservationsRequest)->rules()); + + $this->assertTrue($validator->fails()); + $this->assertSame( + ['tipo_pago', 'page', 'per_page'], + array_keys($validator->errors()->toArray()), + ); + } + + public function test_export_request_requires_a_valid_timezone(): void + { + $validator = Validator::make([ + 'tipo_pago' => 'sin_cargo', + 'timezone' => 'Invalid/Timezone', + ], (new ExportEntryReservationsRequest)->rules()); + + $this->assertTrue($validator->fails()); + $this->assertArrayHasKey('timezone', $validator->errors()->toArray()); + } + + public function test_exports_filtered_reservations_to_pdf_and_excel(): void + { + $service = $this->service(); + $user = User::findOrFail(1); + $service->reserve($user, (string) Str::uuid(), $this->rows()); + $reservations = $service->reservationsForExport($user, ['tipo_pago' => 'otro_metodo']); + $tenant = Tenant::query()->firstOrFail(); + $tenant->setAttribute('nombre', 'Desfile Pura Tendencia'); + $report = new EntryReservationReportService; + + $this->assertCount(1, $reservations); + $this->assertSame( + $reservations->sole()->ticket_id, + $service->reservationTicket($user, $reservations->sole()->id)->id, + ); + $this->assertSame('Otro método', $report->rows($reservations)->sole()['pago']); + + $pdf = (new EntryReservationPdfService($report))->download( + $tenant, + $reservations, + 'America/Argentina/Buenos_Aires', + ); + $this->assertSame('application/pdf', $pdf->headers->get('content-type')); + + $excel = (new EntryReservationExcelService($report))->download( + $tenant, + $reservations, + 'America/Argentina/Buenos_Aires', + ); + $this->assertSame( + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + $excel->headers->get('content-type'), + ); + } + + public function test_real_ticket_generator_links_admin_variant_and_reservation(): void + { + $validity = Mockery::mock(TicketValidityResolver::class); + $validity->shouldReceive('resolveVariant')->andReturn( + ResolvedTicketValidity::unrestricted(), + ); + $service = new EntryReservationService(new TicketGeneratorService($validity)); + $result = $service->reserve(User::findOrFail(1), (string) Str::uuid(), $this->rows()); + foreach ($result as $reservation) { + $this->assertDatabaseHas('tickets', [ + 'id' => $reservation->ticket_id, 'user_id' => 1, + 'source_variant_id' => $reservation->variant_id, + 'source_catalog_item_id' => 1, 'tenant_code' => 'desfile_pura_tendencia', + 'source_purchase_item_id' => null, + ]); + $this->assertTrue(Str::isUuid($reservation->ticket->ticket)); + } + } + + public function test_request_rejects_a_user_from_another_tenant(): void + { + $request = new StoreEntryReservationsRequest; + $request->setUserResolver(fn () => new User(['tenant_codigo' => 'other'])); + $this->assertFalse($request->authorize()); + } +} diff --git a/tests/Feature/Forms/AdminAppDesfileEntryReservationFormControllerTest.php b/tests/Feature/Forms/AdminAppDesfileEntryReservationFormControllerTest.php new file mode 100644 index 00000000..d7a90e93 --- /dev/null +++ b/tests/Feature/Forms/AdminAppDesfileEntryReservationFormControllerTest.php @@ -0,0 +1,145 @@ +seed(AuthorizationSeeder::class); + } + + public function test_authentication_is_required(): void + { + $this->getJson('/api/v1/adminapp/forms/desfile/entry-reservation') + ->assertUnauthorized(); + } + + public function test_the_tenant_must_have_the_reservations_menu(): void + { + $tenant = $this->createTenant('without_reservations'); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->getJson('/api/v1/adminapp/forms/desfile/entry-reservation') + ->assertNotFound(); + } + + public function test_it_returns_payment_types_and_empty_variant_options_without_a_catalog(): void + { + $tenant = $this->createTenant('desfile_pura_tendencia'); + $this->grantReservationsMenu($tenant); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->getJson('/api/v1/adminapp/forms/desfile/entry-reservation') + ->assertOk() + ->assertExactJson([ + 'data' => [ + 'payment_types' => [ + ['value' => 'sin_cargo', 'label' => 'Sin cargo'], + ['value' => 'otro_metodo', 'label' => 'Otro método'], + ], + 'fields' => [ + ['key' => 'tipo', 'label' => 'Tipo', 'options' => []], + ['key' => 'sector', 'label' => 'Sector', 'options' => []], + ['key' => 'fila', 'label' => 'Fila', 'options' => []], + ['key' => 'asiento', 'label' => 'Asiento', 'options' => []], + ], + 'variants' => [], + ], + ]); + } + + public function test_it_returns_only_available_combinations_without_reserving_stock(): void + { + $tenant = $this->createTenant('desfile_pura_tendencia'); + $this->grantReservationsMenu($tenant); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + $entry = CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, 'slug' => 'entrada', 'nombre' => 'Entrada', 'precio' => 100, + ]); + $attributes = []; + foreach (['tipo', 'sector', 'fila', 'asiento'] as $code) { + $attribute = Attribute::query()->create([ + 'tenant_codigo' => $tenant->codigo, 'codigo' => $code, + 'nombre' => $code, 'type' => FieldType::Select, + ]); + $attributes[$code] = $entry->itemAttributes()->create(['attribute_id' => $attribute->id]); + } + $availableId = null; + foreach (['available', 'reserved', 'sold', 'disabled', 'administrative'] as $index => $state) { + $inventory = Inventory::query()->create([ + 'real_stock' => $state === 'sold' ? 0 : 1, + 'reserved_stock' => $state === 'reserved' ? 1 : 0, + ]); + $variant = $entry->variants()->create([ + 'inventory_id' => $inventory->id, 'precio' => 100, + 'sales_disabled_at' => $state === 'disabled' ? now() : null, + ]); + foreach ($attributes as $code => $attribute) { + $variant->definitions()->create([ + 'item_attribute_id' => $attribute->id, + 'value' => $code === 'asiento' ? (string) ($index + 1) : '1', + ]); + } + if ($state === 'administrative') { + $variant->desfileEntryReservations()->create([ + 'fecha_reserva' => now(), 'importe' => 0, 'tipo_pago' => 'sin_cargo', + ]); + } + if ($state === 'available') { + $availableId = $variant->id; + } + } + $this->getJson('/api/v1/adminapp/forms/desfile/entry-reservation') + ->assertOk()->assertJsonCount(1, 'data.variants') + ->assertJsonPath('data.variants.0.id', $availableId) + ->assertJsonPath('data.fields.3.options', [['value' => '1', 'label' => '1']]); + $this->assertDatabaseCount('desfile_entry_reservations', 1); + $this->assertSame(1, (int) Inventory::query()->sum('reserved_stock')); + } + + private function createTenant(string $code): Tenant + { + return Tenant::query()->create([ + 'codigo' => $code, + 'nombre' => str($code)->headline(), + 'dominio' => "{$code}.test", + ]); + } + + private function createAdminAppUser(Tenant $tenant): User + { + return User::factory()->create([ + 'rol_codigo' => RoleCode::AdminApp->value, + 'tenant_codigo' => $tenant->codigo, + ]); + } + + private function grantReservationsMenu(Tenant $tenant): void + { + $menu = Menu::query()->create([ + 'code' => 'adminapp.desfile.reservas', + 'label' => 'Reserva de Tickets', + 'route' => '/admin/desfile/reservas', + ]); + + $tenant->menues()->attach($menu->code); + } +} diff --git a/tests/Unit/Catalog/VariantAvailabilityTest.php b/tests/Unit/Catalog/VariantAvailabilityTest.php new file mode 100644 index 00000000..24b37cd7 --- /dev/null +++ b/tests/Unit/Catalog/VariantAvailabilityTest.php @@ -0,0 +1,60 @@ +id = 1; + $reserved->setRelation('desfileEntryReservations', new Collection([new EntryReservation])); + $available = new Variant; + $available->id = 2; + $available->setRelation('desfileEntryReservations', new Collection); + $item = new CatalogItem(['inventory_policy' => InventoryPolicy::Unlimited]); + $item->setRelation('variants', new Collection([$reserved, $available])); + + $this->assertSame([2], $item->visibleVariants()->modelKeys()); + $this->assertSame([2], $item->visibleVariants(1)->modelKeys()); + } + + public function test_sale_closure_and_variant_status_apply_without_the_cart_exception(): void + { + $active = new Variant; + $active->id = 1; + $disabled = new Variant(['sales_disabled_at' => now()]); + $disabled->id = 2; + $replaced = new Variant(['replaced_by_variant_id' => 1]); + $replaced->id = 3; + $item = new CatalogItem(['inventory_policy' => InventoryPolicy::Unlimited]); + $item->setRelation('variants', new Collection([$active, $disabled, $replaced])); + + $this->assertSame([1], $item->visibleVariants()->modelKeys()); + $item->sales_end_at = now()->subMinute(); + $this->assertSame([], $item->visibleVariants()->modelKeys()); + $this->assertSame([1], $item->visibleVariants(1)->modelKeys()); + } + + public function test_only_the_cart_can_include_its_own_out_of_stock_variant(): void + { + $variant = new Variant; + $variant->id = 1; + $variant->setRelation('inventory', new Inventory(['real_stock' => 1, 'reserved_stock' => 1])); + $item = new CatalogItem(['inventory_policy' => InventoryPolicy::Tracked]); + $item->setRelation('variants', new Collection([$variant])); + + $this->assertSame([], $item->visibleVariants()->modelKeys()); + $this->assertSame([1], $item->visibleVariants(1)->modelKeys()); + $item->inventory_policy = InventoryPolicy::Unlimited; + $this->assertSame([1], $item->visibleVariants()->modelKeys()); + } +} diff --git a/tests/Unit/Desfile/EntryReservationTest.php b/tests/Unit/Desfile/EntryReservationTest.php new file mode 100644 index 00000000..e422a693 --- /dev/null +++ b/tests/Unit/Desfile/EntryReservationTest.php @@ -0,0 +1,33 @@ +assertSame([ + ['value' => 'sin_cargo', 'label' => 'Sin cargo'], + ['value' => 'otro_metodo', 'label' => 'Otro método'], + ], EntryReservationPaymentType::options()); + } + + public function test_it_defines_the_requested_fillable_fields(): void + { + $reservation = new EntryReservation; + + $this->assertSame([ + 'variant_id', + 'ticket_id', + 'inventory_id', + 'batch_id', + 'fecha_reserva', + 'importe', + 'tipo_pago', + ], $reservation->getFillable()); + } +}