feat(catalog): add sales_end_at field to catalog items and implement sale logic

feat(event): include catalog items in event resource and update event service
test(seeder): add FiestaTradicionArrufoSeeder for event and catalog item setup
config: set event timezone for local event dates
This commit is contained in:
2026-09-18 16:37:40 -03:00
parent dc5134a6ec
commit b0702b7e15
11 changed files with 221 additions and 1 deletions

View File

@@ -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<int, Variant> */
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() && (

View File

@@ -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();
}

View File

@@ -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([

View File

@@ -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<CatalogItem, $this> */
public function catalogItems(): HasMany
{
return $this->hasMany(CatalogItem::class)->whereAvailable()->orderBy('group_order')->orderBy('id');
}
/** @return BelongsToMany<SocialMedia, $this> */
public function socialMedia(): BelongsToMany
{

View File

@@ -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()),
];
}
}

View File

@@ -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',
]);
}
}

View File

@@ -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

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('catalog_items', function (Blueprint $table): void {
$table->timestamp('sales_end_at')->nullable()->after('precio');
});
}
public function down(): void
{
Schema::table('catalog_items', function (Blueprint $table): void {
$table->dropColumn('sales_end_at');
});
}
};

View File

@@ -33,6 +33,7 @@ class DatabaseSeeder extends Seeder
BrandSeeder::class,
ProductCatalogFromImagesSeeder::class,
FiestaFutbolInfantilProductSeeder::class,
FiestaTradicionArrufoSeeder::class,
TelepagosIntegrationSeeder::class,
EmailIntegrationSeeder::class,
MenuSeeder::class,

View File

@@ -0,0 +1,109 @@
<?php
namespace Database\Seeders;
use App\Domains\Commerce\Catalog\Enums\InventoryPolicy;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\Attribute;
use App\Domains\Commerce\Catalog\Services\CatalogService;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Event\Models\Event;
use App\Shared\Attachable\Services\AttachmentService;
use App\Shared\Enums\FieldType;
use Illuminate\Database\Seeder;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon;
use RuntimeException;
class FiestaTradicionArrufoSeeder extends Seeder
{
private const TITLE = '26.º Fiesta de la Tradición y 4.º Encuentro de Agrupaciones Gauchas';
private const IMAGE = __DIR__.'/assets/fiesta-tradicion-arrufo-2026.png';
public function __construct(
private readonly CatalogService $catalog,
private readonly AttachmentService $attachments,
) {}
public function run(): void
{
$tenant = Tenant::query()->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<string, mixed> $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);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 786 KiB