From 969e99b3ae56aa412e2c1fb15a116f2778fa2bb6 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 21 Sep 2026 16:05:18 -0300 Subject: [PATCH] feat(ticketing): add service and command for converting validity times to UTC --- .../ConvertValidityTimesToUtcService.php | 77 +++++++++++++++++++ .../Ticketing/Ticket/documentacion/README.md | 29 +++++++ routes/console.php | 34 +++++++- .../Ticket/ConvertValidityTimesToUtcTest.php | 74 ++++++++++++++++++ 4 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 app/Domains/Ticketing/Ticket/Services/ConvertValidityTimesToUtcService.php create mode 100644 tests/Feature/Ticket/ConvertValidityTimesToUtcTest.php diff --git a/app/Domains/Ticketing/Ticket/Services/ConvertValidityTimesToUtcService.php b/app/Domains/Ticketing/Ticket/Services/ConvertValidityTimesToUtcService.php new file mode 100644 index 0000000..adce92d --- /dev/null +++ b/app/Domains/Ticketing/Ticket/Services/ConvertValidityTimesToUtcService.php @@ -0,0 +1,77 @@ + $ids + * @return array> + */ + public function run(array $ids, string $timezone, bool $apply = false, bool $all = false): array + { + if (! in_array($timezone, DateTimeZone::listIdentifiers(DateTimeZone::ALL_WITH_BC), true)) { + throw new InvalidArgumentException('Zona horaria de origen inválida.'); + } + if ($all && $ids !== []) { + throw new InvalidArgumentException('Usar --all o --ids, no ambos.'); + } + if (! $all && $ids === []) { + throw new InvalidArgumentException('Indicar --all para todos los fixed_window o --ids para una selección.'); + } + + return DB::transaction(function () use ($ids, $timezone, $apply): array { + $query = DB::table('validity_times')->orderBy('id'); + if ($ids === []) { + $query->where('type', 'fixed_window'); + } else { + $query->whereIn('id', $ids); + } + if ($apply) { + $query->lockForUpdate(); + } + $rows = $query->get(); + if ($ids !== [] && $rows->count() !== count(array_unique($ids))) { + throw new InvalidArgumentException('Uno o más IDs no existen. No se modificó ningún registro.'); + } + if ($rows->contains(fn ($row): bool => $row->type !== 'fixed_window')) { + throw new InvalidArgumentException('Sólo se admiten IDs de fixed_window. No se modificó ningún registro.'); + } + $result = []; + foreach ($rows as $row) { + $start = $this->convert($row->fixed_starts_at, $timezone); + $end = $this->convert($row->fixed_expires_at, $timezone); + $result[] = [ + 'id' => $row->id, + 'status' => $apply ? 'convertido' : 'propuesta; verificar origen local', + 'original_start' => $row->fixed_starts_at, + 'original_end' => $row->fixed_expires_at, + 'utc_start' => $start, + 'utc_end' => $end, + ]; + if (! $apply) { + continue; + } + DB::table('validity_times')->where('id', $row->id)->update([ + 'fixed_starts_at' => $start, + 'fixed_expires_at' => $end, + ]); + } + + return $result; + }); + } + + private function convert(?string $value, string $timezone): ?string + { + return $value === null ? null : CarbonImmutable::parse($value, $timezone)->utc()->format('Y-m-d H:i:s'); + } +} diff --git a/app/Domains/Ticketing/Ticket/documentacion/README.md b/app/Domains/Ticketing/Ticket/documentacion/README.md index 4eb7a2c..ba2ef02 100644 --- a/app/Domains/Ticketing/Ticket/documentacion/README.md +++ b/app/Domains/Ticketing/Ticket/documentacion/README.md @@ -92,3 +92,32 @@ Bajo `/v1/adminapp/tenant`, protegido por `auth:sanctum`, `adminapp.tenant` y el ## Dependencias y reglas Depende de `Purchase`, `Catalog`, `Tenant` y `Auth`. La generación debe ser idempotente ante reintentos del evento. `TicketNotAvailableException` y `TicketGenerationException` separan indisponibilidad de errores de generación. + +## Conversión de datos históricos a UTC (homo / producción) + +El comando no requiere migraciones adicionales ni crea una tabla de registro. +Para convertir todos los registros de tipo `fixed_window` desde hora argentina: + +```bash +# Vista previa: no escribe en la base +php artisan tickets:convert-validity-times-to-utc --all +# Aplicar a todos los fixed_window +php artisan tickets:convert-validity-times-to-utc --all --apply +``` + +También permite seleccionar IDs específicos (los siguientes son ejemplos): + +```bash +php artisan tickets:convert-validity-times-to-utc --ids=41,42 +php artisan tickets:convert-validity-times-to-utc --ids=41,42 --apply +``` + +`--all` y `--ids` son excluyentes. El origen por defecto es +`America/Argentina/Buenos_Aires`; se puede indicar otra zona IANA con `--source-timezone`. +Cada extremo conserva su fecha original y se convierte por separado, incluso si la ventana abarca +varios días. Los límites nulos y los `time_window` no se modifican. La aplicación es transaccional. + +No hay registro ni detección automática de conversiones anteriores: repetir `--apply` vuelve a +convertir los valores actuales. La migración original `2026_09_21_040000_add_timezone_to_tenants.php` +ya convierte las ventanas asociadas a eventos; no ejecutar este comando sobre esas ventanas si +ya fueron convertidas. `--all` incluye absolutamente todos los `fixed_window`, sin esa distinción. diff --git a/routes/console.php b/routes/console.php index 5164f0d..3d9231a 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,9 +1,10 @@ purpose('Delete tenant sales, carts, tickets and reservations while preserving users and catalog'); + +Artisan::command( + 'tickets:convert-validity-times-to-utc + {--ids= : IDs separados por coma, confirmados como hora local} + {--all : Seleccionar todos los validity_times de tipo fixed_window} + {--source-timezone=America/Argentina/Buenos_Aires : Zona de los valores guardados} + {--apply : Aplicar la conversión; sin esta opción sólo muestra una vista previa}', + function (ConvertValidityTimesToUtcService $service): int { + $input = trim((string) $this->option('ids')); + if ($input !== '' && ! preg_match('/^[1-9][0-9]*(\s*,\s*[1-9][0-9]*)*$/', $input)) { + $this->error('--ids debe contener enteros positivos separados por coma.'); + + return self::FAILURE; + } + $ids = $input === '' ? [] : array_values(array_unique(array_map('intval', explode(',', $input)))); + $this->warn('Los valores seleccionados se interpretan como hora local. Repetir --apply vuelve a convertirlos; no hay registro de ejecuciones.'); + try { + $result = $service->run($ids, (string) $this->option('source-timezone'), (bool) $this->option('apply'), (bool) $this->option('all')); + } catch (Throwable $exception) { + $this->error($exception->getMessage()); + + return self::FAILURE; + } + $this->table(['ID', 'Estado', 'Inicio original', 'Fin original', 'Inicio UTC', 'Fin UTC'], $result); + $this->info($this->option('apply') + ? 'Conversión aplicada. Los time_window no fueron modificados.' + : 'Vista previa: no se modificó ningún registro.'); + + return self::SUCCESS; + } +)->purpose('Convertir fixed_window locales a UTC por IDs o todos, con vista previa'); diff --git a/tests/Feature/Ticket/ConvertValidityTimesToUtcTest.php b/tests/Feature/Ticket/ConvertValidityTimesToUtcTest.php new file mode 100644 index 0000000..a6fc744 --- /dev/null +++ b/tests/Feature/Ticket/ConvertValidityTimesToUtcTest.php @@ -0,0 +1,74 @@ +id(); + $table->string('type'); + $table->time('start_time')->nullable(); + $table->time('end_time')->nullable(); + $table->dateTime('fixed_starts_at')->nullable(); + $table->dateTime('fixed_expires_at')->nullable(); + }); + DB::table('validity_times')->insert([ + ['id' => 1, 'type' => 'fixed_window', 'fixed_starts_at' => '2026-09-21 07:00:00', 'fixed_expires_at' => '2026-09-23 23:59:00'], + ['id' => 2, 'type' => 'fixed_window', 'fixed_starts_at' => null, 'fixed_expires_at' => '2026-09-24 23:59:00'], + ]); + DB::table('validity_times')->insert(['id' => 3, 'type' => 'time_window', 'start_time' => '07:00:00', 'end_time' => '10:00:00']); + } + + public function test_preview_does_not_modify_data(): void + { + $this->artisan('tickets:convert-validity-times-to-utc', ['--all' => true])->assertSuccessful(); + $this->assertDatabaseHas('validity_times', ['id' => 1, 'fixed_starts_at' => '2026-09-21 07:00:00']); + } + + public function test_explicit_conversion_preserves_multiple_days_nulls_and_time_windows(): void + { + $this->artisan('tickets:convert-validity-times-to-utc', ['--ids' => '1,2', '--apply' => true])->assertSuccessful(); + $this->assertDatabaseHas('validity_times', [ + 'id' => 1, 'fixed_starts_at' => '2026-09-21 10:00:00', 'fixed_expires_at' => '2026-09-24 02:59:00', + ]); + $this->assertDatabaseHas('validity_times', ['id' => 2, 'fixed_starts_at' => null, 'fixed_expires_at' => '2026-09-25 02:59:00']); + $this->assertDatabaseHas('validity_times', ['id' => 3, 'start_time' => '07:00:00', 'end_time' => '10:00:00']); + } + + public function test_all_converts_every_fixed_window_without_a_migration(): void + { + $this->artisan('tickets:convert-validity-times-to-utc', ['--all' => true, '--apply' => true])->assertSuccessful(); + $this->assertDatabaseHas('validity_times', ['id' => 1, 'fixed_starts_at' => '2026-09-21 10:00:00', 'fixed_expires_at' => '2026-09-24 02:59:00']); + $this->assertDatabaseHas('validity_times', ['id' => 2, 'fixed_starts_at' => null, 'fixed_expires_at' => '2026-09-25 02:59:00']); + $this->assertDatabaseHas('validity_times', ['id' => 3, 'start_time' => '07:00:00', 'end_time' => '10:00:00']); + } + + public function test_ids_leave_unselected_fixed_windows_unchanged(): void + { + $this->artisan('tickets:convert-validity-times-to-utc', ['--ids' => '1', '--apply' => true])->assertSuccessful(); + $this->assertDatabaseHas('validity_times', ['id' => 2, 'fixed_expires_at' => '2026-09-24 23:59:00']); + } + + public function test_invalid_scope_is_rejected_before_any_write(): void + { + foreach ([[], ['--all' => true, '--ids' => '1'], ['--ids' => '1,999'], ['--ids' => '1,3'], ['--ids' => 'bad'], ['--ids' => '1', '--source-timezone' => 'bad']] as $options) { + $this->artisan('tickets:convert-validity-times-to-utc', $options + ['--apply' => true])->assertFailed(); + } + $this->assertDatabaseHas('validity_times', ['id' => 1, 'fixed_starts_at' => '2026-09-21 07:00:00']); + } + + public function test_failure_rolls_back_all_updates(): void + { + DB::statement("CREATE TRIGGER prevent_second_update BEFORE UPDATE ON validity_times WHEN OLD.id = 2 BEGIN SELECT RAISE(ABORT, 'test failure'); END"); + $this->artisan('tickets:convert-validity-times-to-utc', ['--ids' => '1,2', '--apply' => true])->assertFailed(); + $this->assertDatabaseHas('validity_times', ['id' => 1, 'fixed_starts_at' => '2026-09-21 07:00:00']); + } +}