feat(ticket): add command to convert fixed_window validity times between local timezone and UTC
This commit is contained in:
@@ -10,68 +10,56 @@ use InvalidArgumentException;
|
|||||||
class ConvertValidityTimesToUtcService
|
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<int, int> $ids
|
* @param array<int, int> $ids
|
||||||
* @return array<int, array<string, mixed>>
|
* @return array<int, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
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)) {
|
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 !== []) {
|
if ($all === ($ids !== [])) {
|
||||||
throw new InvalidArgumentException('Usar --all o --ids, no ambos.');
|
throw new InvalidArgumentException('Indicar --all o --ids, exclusivamente una opción.');
|
||||||
}
|
|
||||||
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 {
|
return DB::transaction(function () use ($ids, $timezone, $apply, $all, $reverse): array {
|
||||||
$query = DB::table('validity_times')->orderBy('id');
|
$query = DB::table('validity_times')->orderBy('id');
|
||||||
if ($ids === []) {
|
$all ? $query->where('type', 'fixed_window') : $query->whereIn('id', $ids);
|
||||||
$query->where('type', 'fixed_window');
|
|
||||||
} else {
|
|
||||||
$query->whereIn('id', $ids);
|
|
||||||
}
|
|
||||||
if ($apply) {
|
if ($apply) {
|
||||||
$query->lockForUpdate();
|
$query->lockForUpdate();
|
||||||
}
|
}
|
||||||
$rows = $query->get();
|
$rows = $query->get();
|
||||||
if ($ids !== [] && $rows->count() !== count(array_unique($ids))) {
|
if (! $all && $rows->count() !== count(array_unique($ids))) {
|
||||||
throw new InvalidArgumentException('Uno o más IDs no existen. No se modificó ningún registro.');
|
throw new InvalidArgumentException('Uno o más IDs no existen.');
|
||||||
}
|
}
|
||||||
if ($rows->contains(fn ($row): bool => $row->type !== 'fixed_window')) {
|
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 = [];
|
$result = [];
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
$start = $this->convert($row->fixed_starts_at, $timezone);
|
$convert = fn (?string $value): ?string => $value === null
|
||||||
$end = $this->convert($row->fixed_expires_at, $timezone);
|
? 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[] = [
|
$result[] = [
|
||||||
'id' => $row->id,
|
'id' => $row->id,
|
||||||
'status' => $apply ? 'convertido' : 'propuesta; verificar origen local',
|
|
||||||
'original_start' => $row->fixed_starts_at,
|
'original_start' => $row->fixed_starts_at,
|
||||||
'original_end' => $row->fixed_expires_at,
|
'original_end' => $row->fixed_expires_at,
|
||||||
'utc_start' => $start,
|
'result_start' => $start,
|
||||||
'utc_end' => $end,
|
'result_end' => $end,
|
||||||
];
|
];
|
||||||
if (! $apply) {
|
if ($apply) {
|
||||||
continue;
|
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;
|
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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`
|
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 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.
|
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.
|
||||||
|
|||||||
@@ -185,10 +185,11 @@ Artisan::command(
|
|||||||
|
|
||||||
Artisan::command(
|
Artisan::command(
|
||||||
'tickets:convert-validity-times-to-utc
|
'tickets:convert-validity-times-to-utc
|
||||||
{--ids= : IDs separados por coma, confirmados como hora local}
|
{--ids= : IDs separados por coma}
|
||||||
{--all : Seleccionar todos los validity_times de tipo fixed_window}
|
{--all : Seleccionar todos los fixed_window}
|
||||||
{--source-timezone=America/Argentina/Buenos_Aires : Zona de los valores guardados}
|
{--source-timezone=America/Argentina/Buenos_Aires : Zona local; destino cuando se usa --reverse}
|
||||||
{--apply : Aplicar la conversión; sin esta opción sólo muestra una vista previa}',
|
{--reverse : Convertir desde UTC hacia la zona local}
|
||||||
|
{--apply : Aplicar; sin esta opción sólo muestra una vista previa}',
|
||||||
function (ConvertValidityTimesToUtcService $service): int {
|
function (ConvertValidityTimesToUtcService $service): int {
|
||||||
$input = trim((string) $this->option('ids'));
|
$input = trim((string) $this->option('ids'));
|
||||||
if ($input !== '' && ! preg_match('/^[1-9][0-9]*(\s*,\s*[1-9][0-9]*)*$/', $input)) {
|
if ($input !== '' && ! preg_match('/^[1-9][0-9]*(\s*,\s*[1-9][0-9]*)*$/', $input)) {
|
||||||
@@ -197,19 +198,20 @@ Artisan::command(
|
|||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
}
|
}
|
||||||
$ids = $input === '' ? [] : array_values(array_unique(array_map('intval', explode(',', $input))));
|
$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 {
|
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) {
|
} catch (Throwable $exception) {
|
||||||
$this->error($exception->getMessage());
|
$this->error($exception->getMessage());
|
||||||
|
|
||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
}
|
}
|
||||||
$this->table(['ID', 'Estado', 'Inicio original', 'Fin original', 'Inicio UTC', 'Fin UTC'], $result);
|
$this->table(['ID', 'Inicio original', 'Fin original', 'Inicio resultante', 'Fin resultante'], $result);
|
||||||
$this->info($this->option('apply')
|
$this->info($this->option('apply') ? 'Conversión aplicada.' : 'Vista previa: no se modificaron datos.');
|
||||||
? 'Conversión aplicada. Los time_window no fueron modificados.'
|
|
||||||
: 'Vista previa: no se modificó ningún registro.');
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
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');
|
||||||
|
|||||||
@@ -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']);
|
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
|
public function test_all_round_trip_restores_exact_values_across_dates_and_nulls(): 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
|
|
||||||
{
|
{
|
||||||
|
$before = DB::table('validity_times')->orderBy('id')->get()->toJson();
|
||||||
$this->artisan('tickets:convert-validity-times-to-utc', ['--all' => true, '--apply' => true])->assertSuccessful();
|
$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' => 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->artisan('tickets:convert-validity-times-to-utc', ['--all' => true, '--reverse' => true, '--apply' => true])->assertSuccessful();
|
||||||
$this->assertDatabaseHas('validity_times', ['id' => 3, 'start_time' => '07:00:00', 'end_time' => '10:00:00']);
|
$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' => 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) {
|
$before = DB::table('validity_times')->orderBy('id')->get()->toJson();
|
||||||
$this->artisan('tickets:convert-validity-times-to-utc', $options + ['--apply' => true])->assertFailed();
|
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");
|
DB::statement("CREATE TRIGGER fail_second 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();
|
$before = DB::table('validity_times')->orderBy('id')->get()->toJson();
|
||||||
$this->assertDatabaseHas('validity_times', ['id' => 1, 'fixed_starts_at' => '2026-09-21 07:00:00']);
|
$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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user