From d33b435ba5b508905a6cbb7b5129b0f46f5282b8 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 21 Sep 2026 16:22:48 -0300 Subject: [PATCH] feat(ticket): add command to convert fixed_window validity times between local timezone and UTC --- .../ConvertValidityTimesToUtcService.php | 58 ++++++++---------- .../Ticketing/Ticket/documentacion/README.md | 16 +++++ routes/console.php | 24 ++++---- .../Ticket/ConvertValidityTimesToUtcTest.php | 59 ++++++++++--------- 4 files changed, 82 insertions(+), 75 deletions(-) diff --git a/app/Domains/Ticketing/Ticket/Services/ConvertValidityTimesToUtcService.php b/app/Domains/Ticketing/Ticket/Services/ConvertValidityTimesToUtcService.php index adce92d..cdd006a 100644 --- a/app/Domains/Ticketing/Ticket/Services/ConvertValidityTimesToUtcService.php +++ b/app/Domains/Ticketing/Ticket/Services/ConvertValidityTimesToUtcService.php @@ -10,68 +10,56 @@ use InvalidArgumentException; class ConvertValidityTimesToUtcService { /** - * IDs or the all flag select values the operator has confirmed are still local. - * UTC cannot be inferred reliably from a timezone-less database timestamp. - * * @param array $ids * @return array> */ - public function run(array $ids, string $timezone, bool $apply = false, bool $all = false): array + public function run(array $ids, string $timezone, bool $apply = false, bool $all = false, bool $reverse = false): array { if (! in_array($timezone, DateTimeZone::listIdentifiers(DateTimeZone::ALL_WITH_BC), true)) { - throw new InvalidArgumentException('Zona horaria de origen inválida.'); + throw new InvalidArgumentException('Zona horaria 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.'); + if ($all === ($ids !== [])) { + throw new InvalidArgumentException('Indicar --all o --ids, exclusivamente una opción.'); } - return DB::transaction(function () use ($ids, $timezone, $apply): array { + return DB::transaction(function () use ($ids, $timezone, $apply, $all, $reverse): array { $query = DB::table('validity_times')->orderBy('id'); - if ($ids === []) { - $query->where('type', 'fixed_window'); - } else { - $query->whereIn('id', $ids); - } + $all ? $query->where('type', 'fixed_window') : $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 (! $all && $rows->count() !== count(array_unique($ids))) { + throw new InvalidArgumentException('Uno o más IDs no existen.'); } 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.'); + throw new InvalidArgumentException('Sólo se admiten IDs de fixed_window.'); } + $source = $reverse ? 'UTC' : $timezone; + $target = $reverse ? $timezone : 'UTC'; $result = []; foreach ($rows as $row) { - $start = $this->convert($row->fixed_starts_at, $timezone); - $end = $this->convert($row->fixed_expires_at, $timezone); + $convert = fn (?string $value): ?string => $value === null + ? null + : CarbonImmutable::parse($value, $source)->setTimezone($target)->format('Y-m-d H:i:s'); + $start = $convert($row->fixed_starts_at); + $end = $convert($row->fixed_expires_at); $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, + 'result_start' => $start, + 'result_end' => $end, ]; - if (! $apply) { - continue; + if ($apply) { + DB::table('validity_times')->where('id', $row->id)->update([ + 'fixed_starts_at' => $start, + 'fixed_expires_at' => $end, + ]); } - 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 ba2ef02..e460d5c 100644 --- a/app/Domains/Ticketing/Ticket/documentacion/README.md +++ b/app/Domains/Ticketing/Ticket/documentacion/README.md @@ -121,3 +121,19 @@ No hay registro ni detección automática de conversiones anteriores: repetir `- 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. + +### Revertir una conversión + +`--reverse` convierte los valores actuales desde UTC hacia la zona local, con el mismo alcance +`--all` o `--ids`. Sin `--apply` sólo muestra la vista previa. + +```bash +php artisan tickets:convert-validity-times-to-utc --all --reverse +php artisan tickets:convert-validity-times-to-utc --all --reverse --apply +php artisan tickets:convert-validity-times-to-utc --ids=41,42 --reverse --apply +``` + +Con `--reverse`, `--source-timezone` indica la zona local de destino (por defecto +`America/Argentina/Buenos_Aires`). Las fechas de cada extremo y los límites nulos se respetan; +los `time_window` no se modifican. Revierte una conversión si se seleccionan los mismos registros +sin ediciones intermedias. No recupera una copia histórica ni detecta conversiones previas. diff --git a/routes/console.php b/routes/console.php index 3d9231a..b90414f 100644 --- a/routes/console.php +++ b/routes/console.php @@ -185,10 +185,11 @@ Artisan::command( 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}', + {--ids= : IDs separados por coma} + {--all : Seleccionar todos los fixed_window} + {--source-timezone=America/Argentina/Buenos_Aires : Zona local; destino cuando se usa --reverse} + {--reverse : Convertir desde UTC hacia la zona local} + {--apply : Aplicar; 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)) { @@ -197,19 +198,20 @@ Artisan::command( 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.'); + $timezone = (string) $this->option('source-timezone'); + $reverse = (bool) $this->option('reverse'); + $this->info($reverse ? "UTC -> {$timezone}" : "{$timezone} -> UTC"); + $this->warn('Cada ejecución con --apply transforma los valores actuales; no hay registro ni detección de conversiones anteriores.'); try { - $result = $service->run($ids, (string) $this->option('source-timezone'), (bool) $this->option('apply'), (bool) $this->option('all')); + $result = $service->run($ids, $timezone, (bool) $this->option('apply'), (bool) $this->option('all'), $reverse); } 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.'); + $this->table(['ID', 'Inicio original', 'Fin original', 'Inicio resultante', 'Fin resultante'], $result); + $this->info($this->option('apply') ? 'Conversión aplicada.' : 'Vista previa: no se modificaron datos.'); return self::SUCCESS; } -)->purpose('Convertir fixed_window locales a UTC por IDs o todos, con vista previa'); +)->purpose('Convertir fixed_window entre hora local y UTC, con vista previa'); diff --git a/tests/Feature/Ticket/ConvertValidityTimesToUtcTest.php b/tests/Feature/Ticket/ConvertValidityTimesToUtcTest.php index a6fc744..4cc3ec0 100644 --- a/tests/Feature/Ticket/ConvertValidityTimesToUtcTest.php +++ b/tests/Feature/Ticket/ConvertValidityTimesToUtcTest.php @@ -27,48 +27,49 @@ class ConvertValidityTimesToUtcTest extends TestCase 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 + public function test_all_round_trip_restores_exact_values_across_dates_and_nulls(): void { + $before = DB::table('validity_times')->orderBy('id')->get()->toJson(); $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']); + $this->artisan('tickets:convert-validity-times-to-utc', ['--all' => true, '--reverse' => true, '--apply' => true])->assertSuccessful(); + $this->assertSame($before, DB::table('validity_times')->orderBy('id')->get()->toJson()); } - public function test_ids_leave_unselected_fixed_windows_unchanged(): void + public function test_reverse_preview_shows_local_result_without_writing(): void { - $this->artisan('tickets:convert-validity-times-to-utc', ['--ids' => '1', '--apply' => true])->assertSuccessful(); + $before = DB::table('validity_times')->orderBy('id')->get()->toJson(); + $this->artisan('tickets:convert-validity-times-to-utc', ['--all' => true, '--reverse' => true]) + ->expectsOutputToContain('UTC -> America/Argentina/Buenos_Aires') + ->expectsTable(['ID', 'Inicio original', 'Fin original', 'Inicio resultante', 'Fin resultante'], [ + [1, '2026-09-21 07:00:00', '2026-09-23 23:59:00', '2026-09-21 04:00:00', '2026-09-23 20:59:00'], + [2, null, '2026-09-24 23:59:00', null, '2026-09-24 20:59:00'], + ])->assertSuccessful(); + $this->assertSame($before, DB::table('validity_times')->orderBy('id')->get()->toJson()); + } + + public function test_reverse_by_ids_uses_selected_timezone_and_preserves_other_rows(): void + { + $this->artisan('tickets:convert-validity-times-to-utc', ['--ids' => '1', '--reverse' => true, '--source-timezone' => 'Asia/Tokyo', '--apply' => true])->assertSuccessful(); + $this->assertDatabaseHas('validity_times', ['id' => 1, 'fixed_starts_at' => '2026-09-21 16:00:00', 'fixed_expires_at' => '2026-09-24 08:59:00']); $this->assertDatabaseHas('validity_times', ['id' => 2, 'fixed_expires_at' => '2026-09-24 23:59:00']); + $this->assertDatabaseHas('validity_times', ['id' => 3, 'start_time' => '07:00:00']); } - public function test_invalid_scope_is_rejected_before_any_write(): void + public function test_invalid_scope_fails_without_changes(): 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(); + $before = DB::table('validity_times')->orderBy('id')->get()->toJson(); + foreach ([[], ['--ids' => '1', '--all' => true], ['--ids' => '1,999'], ['--ids' => '1,3'], ['--ids' => 'bad'], ['--all' => true, '--source-timezone' => 'bad']] as $options) { + $this->artisan('tickets:convert-validity-times-to-utc', $options + ['--reverse' => true, '--apply' => true])->assertFailed(); } - $this->assertDatabaseHas('validity_times', ['id' => 1, 'fixed_starts_at' => '2026-09-21 07:00:00']); + $this->assertSame($before, DB::table('validity_times')->orderBy('id')->get()->toJson()); } - public function test_failure_rolls_back_all_updates(): void + public function test_reverse_failure_rolls_back_entire_operation(): 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']); + DB::statement("CREATE TRIGGER fail_second BEFORE UPDATE ON validity_times WHEN OLD.id = 2 BEGIN SELECT RAISE(ABORT, 'test failure'); END"); + $before = DB::table('validity_times')->orderBy('id')->get()->toJson(); + $this->artisan('tickets:convert-validity-times-to-utc', ['--all' => true, '--reverse' => true, '--apply' => true])->assertFailed(); + $this->assertSame($before, DB::table('validity_times')->orderBy('id')->get()->toJson()); } }