diff --git a/app/Domains/Commerce/Catalog/Models/CatalogItem.php b/app/Domains/Commerce/Catalog/Models/CatalogItem.php index bdf29b8..b01ca18 100644 --- a/app/Domains/Commerce/Catalog/Models/CatalogItem.php +++ b/app/Domains/Commerce/Catalog/Models/CatalogItem.php @@ -31,6 +31,7 @@ use Illuminate\Support\Collection; 'group_order', 'descripcion', 'precio', + 'sales_end_at', 'inventory_policy', 'inventory_subject', 'max_units_per_user', @@ -75,6 +76,7 @@ class CatalogItem extends Model 'type' => CatalogItemType::class, 'group_order' => 'integer', 'precio' => 'decimal:2', + 'sales_end_at' => 'datetime', 'inventory_policy' => InventoryPolicy::class, 'inventory_subject' => InventorySubject::class, 'max_units_per_user' => 'integer', @@ -191,6 +193,10 @@ class CatalogItem extends Model public function isAvailable(): bool { + if (! $this->isSaleOpen()) { + return false; + } + if ($this->type === CatalogItemType::Bundle) { $availableStock = $this->availableStock(); @@ -208,6 +214,9 @@ class CatalogItem extends Model public function scopeWhereAvailable(Builder $query): Builder { return $query->where(function (Builder $query): void { + $query->whereNull('catalog_items.sales_end_at') + ->orWhere('catalog_items.sales_end_at', '>', now()); + })->where(function (Builder $query): void { $query ->where(function (Builder $unlimitedQuery): void { $unlimitedQuery @@ -247,10 +256,16 @@ class CatalogItem extends Model }); } + public function isSaleOpen(): bool + { + return $this->sales_end_at === null || now()->lt($this->sales_end_at); + } + /** @return Collection */ public function visibleVariants(?int $includedVariantId = null): Collection { return $this->variants + ->each(fn (Variant $variant) => $variant->setRelation('catalogItem', $this)) ->filter(fn (Variant $variant): bool => $variant->hasOnlyActiveEventDates() && (($includedVariantId !== null && $variant->id === $includedVariantId) || ($variant->isSellable() && ( diff --git a/app/Domains/Commerce/Catalog/Models/Variant.php b/app/Domains/Commerce/Catalog/Models/Variant.php index 766f8a2..3e63704 100644 --- a/app/Domains/Commerce/Catalog/Models/Variant.php +++ b/app/Domains/Commerce/Catalog/Models/Variant.php @@ -96,6 +96,7 @@ class Variant extends Model { return $this->sales_disabled_at === null && $this->replaced_by_variant_id === null + && $this->catalogItem->isSaleOpen() && $this->hasOnlyActiveEventDates(); } diff --git a/app/Domains/Commerce/Purchase/Services/Checkout/CatalogSelectionResolver.php b/app/Domains/Commerce/Purchase/Services/Checkout/CatalogSelectionResolver.php index 6c8888b..24b2484 100644 --- a/app/Domains/Commerce/Purchase/Services/Checkout/CatalogSelectionResolver.php +++ b/app/Domains/Commerce/Purchase/Services/Checkout/CatalogSelectionResolver.php @@ -28,6 +28,12 @@ class CatalogSelectionResolver throw new NotFoundHttpException('Catalog item not found for tenant.'); } + if (! $catalogItem->isSaleOpen()) { + throw ValidationException::withMessages([ + "{$fieldPrefix}.catalog_item_id" => ['La venta de este producto finalizó.'], + ]); + } + if ($catalogItem->isBundle()) { if ($variantId !== null) { throw ValidationException::withMessages([ diff --git a/app/Domains/Ticketing/Event/Models/Event.php b/app/Domains/Ticketing/Event/Models/Event.php index 3f3c960..5389085 100644 --- a/app/Domains/Ticketing/Event/Models/Event.php +++ b/app/Domains/Ticketing/Event/Models/Event.php @@ -4,6 +4,7 @@ namespace App\Domains\Ticketing\Event\Models; use App\Domains\Core\Tenant\Models\SocialMedia; use App\Domains\Core\Tenant\Models\Tenant; +use App\Domains\Commerce\Catalog\Models\CatalogItem; use App\Shared\Attachable\Models\Attachment; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -40,6 +41,12 @@ class Event extends Model return $this->hasMany(EventDate::class)->orderBy('date')->orderBy('time_start'); } + /** @return HasMany */ + public function catalogItems(): HasMany + { + return $this->hasMany(CatalogItem::class)->whereAvailable()->orderBy('group_order')->orderBy('id'); + } + /** @return BelongsToMany */ public function socialMedia(): BelongsToMany { diff --git a/app/Domains/Ticketing/Event/Resources/PublicEventResource.php b/app/Domains/Ticketing/Event/Resources/PublicEventResource.php index ac67e9a..ff51827 100644 --- a/app/Domains/Ticketing/Event/Resources/PublicEventResource.php +++ b/app/Domains/Ticketing/Event/Resources/PublicEventResource.php @@ -2,6 +2,10 @@ namespace App\Domains\Ticketing\Event\Resources; +use App\Domains\Commerce\Catalog\Enums\InventoryPolicy; +use App\Domains\Commerce\Catalog\Models\CatalogItem; +use App\Domains\Commerce\Catalog\Models\Variant; +use Illuminate\Support\Carbon; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -17,8 +21,50 @@ class PublicEventResource extends JsonResource 'location' => $this->location, 'exact_location' => $this->exact_location, 'date_text' => $this->date_text, + 'start_time' => $this->whenLoaded('dates', fn () => $this->dates->first()?->time_start), + 'starts_at' => $this->whenLoaded('dates', function (): ?string { + $first = $this->dates->first(); + if ($first === null) { + return null; + } + + return Carbon::parse( + $first->date->format('Y-m-d').' '.$first->time_start, + config('app.event_timezone'), + )->toISOString(); + }), + 'dates' => $this->whenLoaded('dates', fn () => $this->dates->map(fn ($date): array => [ + 'id' => $date->id, + 'date' => $date->date->format('Y-m-d'), + 'time_start' => $date->time_start, + ])->values()), + 'social_media' => $this->whenLoaded('socialMedia', fn () => $this->socialMedia->map(fn ($social): array => [ + 'code' => $social->code, + 'url' => $social->pivot->url, + ])->values()), 'attachment_id' => $this->attachment_id, 'image' => $this->attachment?->getTemporaryUrl(1440), + 'catalog_items' => $this->whenLoaded('catalogItems', fn () => $this->catalogItems + ->map(fn (CatalogItem $item): array => [ + 'id' => $item->id, + 'name' => $item->nombre, + 'description' => $item->descripcion, + 'price' => $item->precio, + 'image' => $item->attachments->first()?->getTemporaryUrl(1440), + 'requires_selection' => $item->itemAttributes->contains(fn ($attribute): bool => + $attribute->show_in_selector && $attribute->attribute?->codigo !== 'event_date'), + 'maximum_quantity' => $item->inventory_policy === InventoryPolicy::Unlimited + ? null + : $item->availableStock(), + 'variants' => $item->visibleVariants()->map(fn (Variant $variant): array => [ + 'id' => $variant->id, + 'price' => number_format($variant->getPrice(), 2, '.', ''), + 'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(), + 'maximum_quantity' => $item->inventory_policy === InventoryPolicy::Unlimited + ? null + : $variant->inventory?->availableStock(), + ])->values(), + ])->values()), ]; } } diff --git a/app/Domains/Ticketing/Event/Services/PublicEventService.php b/app/Domains/Ticketing/Event/Services/PublicEventService.php index 7cd4da1..b9cca10 100644 --- a/app/Domains/Ticketing/Event/Services/PublicEventService.php +++ b/app/Domains/Ticketing/Event/Services/PublicEventService.php @@ -31,6 +31,16 @@ class PublicEventService && $event->published_at !== null && $event->published_at->isPast(), 404); - return $event->load('attachment'); + return $event->load([ + 'attachment', + 'dates' => fn ($query) => $query->whereNull('rescheduled_to_event_date_id')->whereNull('suspended_at'), + 'socialMedia', + 'catalogItems.attachments', + 'catalogItems.inventory', + 'catalogItems.itemAttributes.attribute', + 'catalogItems.variants.inventory', + 'catalogItems.variants.eventDate', + 'catalogItems.variants.eventDates', + ]); } } diff --git a/config/app.php b/config/app.php index 722f90d..a621533 100644 --- a/config/app.php +++ b/config/app.php @@ -67,6 +67,9 @@ return [ 'timezone' => 'UTC', + // Las fechas de los eventos se ingresan como horarios locales de Argentina. + 'event_timezone' => env('EVENT_TIMEZONE', 'America/Argentina/Buenos_Aires'), + /* |-------------------------------------------------------------------------- | Application Locale Configuration diff --git a/database/migrations/2026_09_18_000900_add_sales_end_at_to_catalog_items.php b/database/migrations/2026_09_18_000900_add_sales_end_at_to_catalog_items.php new file mode 100644 index 0000000..5550088 --- /dev/null +++ b/database/migrations/2026_09_18_000900_add_sales_end_at_to_catalog_items.php @@ -0,0 +1,22 @@ +timestamp('sales_end_at')->nullable()->after('precio'); + }); + } + + public function down(): void + { + Schema::table('catalog_items', function (Blueprint $table): void { + $table->dropColumn('sales_end_at'); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 119ea39..b5be6dc 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -33,6 +33,7 @@ class DatabaseSeeder extends Seeder BrandSeeder::class, ProductCatalogFromImagesSeeder::class, FiestaFutbolInfantilProductSeeder::class, + FiestaTradicionArrufoSeeder::class, TelepagosIntegrationSeeder::class, EmailIntegrationSeeder::class, MenuSeeder::class, diff --git a/database/seeders/FiestaTradicionArrufoSeeder.php b/database/seeders/FiestaTradicionArrufoSeeder.php new file mode 100644 index 0000000..267cb38 --- /dev/null +++ b/database/seeders/FiestaTradicionArrufoSeeder.php @@ -0,0 +1,109 @@ +where('codigo', 'onticket')->first(); + + if ($tenant === null) { + throw new RuntimeException("Tenant 'onticket' no encontrado."); + } + + Attribute::query()->firstOrCreate( + ['tenant_codigo' => $tenant->codigo, 'codigo' => 'event_date'], + ['nombre' => 'Fecha', 'type' => FieldType::EventDate, 'is_required' => true], + ); + + $event = Event::query()->firstOrCreate( + ['tenant_code' => $tenant->codigo, 'title' => self::TITLE], + ['published_at' => now()], + ); + $event->update([ + 'subtitle' => 'Una noche para celebrar nuestras raíces y mantener viva la tradición gaucha.', + 'description' => 'La 26.º Fiesta de la Tradición y 4.º Encuentro de Agrupaciones Gauchas reunirá a agrupaciones, artesanos, pilcheros y público en general para compartir una jornada dedicada a nuestras costumbres y cultura.' + ."\n\n".'Un encuentro para disfrutar de la tradición, la identidad gaucha y el espíritu de camaradería, en el Predio de Doma del Club Unión Deportiva Arrufó.' + ."\n\n".'Organiza: Biblioteca Popular Miguel Ángel Sosa.', + 'location' => 'Predio de Doma del Club Unión Deportiva Arrufó', + ]); + + if ($event->attachment_id === null) { + $event->update(['attachment_id' => $this->attachments->store($this->image(), 'events')->id]); + } + + // La hora de cierre es provisoria hasta que la organización la confirme. + $date = $event->dates()->firstOrCreate( + ['date' => '2026-11-14'], + ['tenant_code' => $tenant->codigo, 'time_start' => '19:00', 'time_end' => '23:59'], + ); + + $this->createItem($tenant, $event, $date->id, [ + 'slug' => 'fiesta-tradicion-arrufo-2026-entrada-anticipada', + 'nombre' => 'Entrada anticipada', + 'descripcion' => 'Entrada para personas de 12 años en adelante. Venta anticipada hasta el 13/11/2026 a las 22:00 h.', + 'precio' => 12000, + 'sales_end_at' => Carbon::parse('2026-11-13 22:00:00', 'America/Argentina/Buenos_Aires')->utc(), + 'group_order' => 1, + ]); + + $this->createItem($tenant, $event, $date->id, [ + 'slug' => 'fiesta-tradicion-arrufo-2026-puesto-feria', + 'nombre' => 'Espacio para puesto en la feria', + 'descripcion' => 'Para artesanos, pilcheros y otros puestos de venta. Incluye una entrada para la persona que atiende el puesto. No se permiten puestos de venta de comidas ni bebidas.', + 'precio' => 50000, + 'group_order' => 2, + ]); + } + + /** @param array $data */ + private function createItem(Tenant $tenant, Event $event, int $dateId, array $data): void + { + $existing = CatalogItem::query()->where('tenant_code', $tenant->codigo)->where('slug', $data['slug'])->first(); + if ($existing !== null) { + $existing->update(['sales_end_at' => $data['sales_end_at'] ?? null]); + return; + } + + $item = $this->catalog->create([ + 'tenant_code' => $tenant->codigo, + 'inventory_policy' => InventoryPolicy::Unlimited->value, + 'has_tickets' => true, + 'attribute_codes' => ['event_date'], + 'hidden_attribute_codes' => ['event_date'], + 'variants' => [['event_date_id' => $dateId]], + 'images' => [$this->image()], + ...$data, + ]); + + $item->forceFill(['event_id' => $event->id])->save(); + } + + private function image(): UploadedFile + { + return new UploadedFile(self::IMAGE, basename(self::IMAGE), 'image/png', null, true); + } +} diff --git a/database/seeders/assets/fiesta-tradicion-arrufo-2026.png b/database/seeders/assets/fiesta-tradicion-arrufo-2026.png new file mode 100644 index 0000000..394da97 Binary files /dev/null and b/database/seeders/assets/fiesta-tradicion-arrufo-2026.png differ