feat(ticketing): add service and command for converting validity times to UTC

This commit is contained in:
2026-09-21 16:05:18 -03:00
parent 3e3b3bfac0
commit 969e99b3ae
4 changed files with 213 additions and 1 deletions

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Domains\Ticketing\Ticket\Services;
use Carbon\CarbonImmutable;
use DateTimeZone;
use Illuminate\Support\Facades\DB;
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<int, int> $ids
* @return array<int, array<string, mixed>>
*/
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');
}
}

View File

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

View File

@@ -1,9 +1,10 @@
<?php
use App\Domains\Core\Auth\Services\AdminCredentialVerifier;
use App\Domains\Commerce\Catalog\Services\ExpireStockReservationsService;
use App\Domains\Commerce\Purchase\Services\TenantTransactionResetService;
use App\Domains\Core\Auth\Services\AdminCredentialVerifier;
use App\Domains\Ticketing\Ticket\Services\BackfillRefundedUnitsService;
use App\Domains\Ticketing\Ticket\Services\ConvertValidityTimesToUtcService;
use App\Domains\Ticketing\Ticket\Services\LoadTestTicketDatasetService;
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
@@ -181,3 +182,34 @@ Artisan::command(
return self::SUCCESS;
},
)->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');

View File

@@ -0,0 +1,74 @@
<?php
namespace Tests\Feature\Ticket;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class ConvertValidityTimesToUtcTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Schema::create('validity_times', function (Blueprint $table): void {
$table->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']);
}
}