diff --git a/app/Domains/Ticket/Services/BackfillRefundedUnitsService.php b/app/Domains/Ticket/Services/BackfillRefundedUnitsService.php new file mode 100644 index 0000000..95c0b40 --- /dev/null +++ b/app/Domains/Ticket/Services/BackfillRefundedUnitsService.php @@ -0,0 +1,121 @@ + */ + public function run(): array + { + $summary = DB::transaction(function (): array { + if (DB::table('inventories')->where('refunded_units', '>', 0)->exists()) { + throw new RuntimeException('El backfill requiere que refunded_units sea cero en todos los inventarios.'); + } + + $counts = []; + $variants = []; + $refundsSeen = 0; + $bundlesSkipped = 0; + + DB::table('ticket_refunds as refunds') + ->join('tickets', 'tickets.id', '=', 'refunds.ticket_id') + ->join('compra_items as purchase_items', 'purchase_items.id', '=', 'refunds.purchase_item_id') + ->leftJoin('catalog_items as purchase_catalog', 'purchase_catalog.id', '=', 'purchase_items.source_catalog_item_id') + ->leftJoin('catalog_items as ticket_catalog', 'ticket_catalog.id', '=', 'tickets.source_catalog_item_id') + ->select([ + 'refunds.id', + 'refunds.ticket_id', + 'tickets.source_variant_id', + 'ticket_catalog.inventory_id', + 'ticket_catalog.inventory_policy', + 'ticket_catalog.type as ticket_catalog_type', + 'purchase_catalog.type as purchase_catalog_type', + ]) + ->chunkById(500, function ($refunds) use (&$counts, &$variants, &$refundsSeen, &$bundlesSkipped): void { + foreach ($refunds as $refund) { + $refundsSeen++; + if ($refund->ticket_catalog_type === 'bundle' || $refund->purchase_catalog_type === 'bundle') { + $bundlesSkipped++; + + continue; + } + + if ($refund->inventory_policy === null) { + throw new RuntimeException("El reembolso {$refund->id} no tiene un producto de catálogo asociado."); + } + + $inventoryId = $refund->source_variant_id === null + ? $refund->inventory_id + : $this->currentVariantInventoryId((int) $refund->source_variant_id, $variants); + + if ($inventoryId === null) { + throw new RuntimeException("El reembolso {$refund->id} no tiene un inventario asociado."); + } + + $counts[$inventoryId]['refunded'] = ($counts[$inventoryId]['refunded'] ?? 0) + 1; + if ($refund->inventory_policy === 'tracked') { + $counts[$inventoryId]['stock'] = ($counts[$inventoryId]['stock'] ?? 0) + 1; + } + } + }, 'refunds.id', 'id'); + + foreach ($counts as $inventoryId => $count) { + $updates = ['refunded_units' => DB::raw('refunded_units + '.$count['refunded'])]; + if (($count['stock'] ?? 0) > 0) { + $updates['real_stock'] = DB::raw('real_stock + '.$count['stock']); + } + + if (DB::table('inventories')->where('id', $inventoryId)->update($updates) !== 1) { + throw new RuntimeException("No se encontró el inventario {$inventoryId} para reponerlo."); + } + } + + $refundedUnitsAdded = array_sum(array_column($counts, 'refunded')); + + return [ + 'refunds_seen' => $refundsSeen, + 'refunds_applied' => $refundedUnitsAdded, + 'bundles_skipped' => $bundlesSkipped, + 'inventories_updated' => count($counts), + 'refunded_units_added' => $refundedUnitsAdded, + 'real_stock_added' => array_sum(array_column($counts, 'stock')), + ]; + }); + + Log::info('inventory.refunded_units_backfill.completed', $summary); + + return $summary; + } + + /** @param array $variants */ + private function currentVariantInventoryId(int $variantId, array &$variants): ?int + { + $visited = []; + + while (true) { + if (isset($visited[$variantId])) { + throw new RuntimeException("La cadena de reemplazos de la variante {$variantId} es circular."); + } + $visited[$variantId] = true; + + if (! array_key_exists($variantId, $variants)) { + $variants[$variantId] = DB::table('variantes') + ->where('id', $variantId) + ->first(['inventory_id', 'replaced_by_variant_id']); + } + $variant = $variants[$variantId]; + if ($variant === null) { + throw new RuntimeException("No se encontró la variante {$variantId} de un ticket reembolsado."); + } + if ($variant->replaced_by_variant_id === null) { + return $variant->inventory_id === null ? null : (int) $variant->inventory_id; + } + + $variantId = (int) $variant->replaced_by_variant_id; + } + } +} diff --git a/database/migrations/2026_09_16_000100_backfill_refunded_units.php b/database/migrations/2026_09_16_000100_backfill_refunded_units.php index 5996107..b31d2a7 100644 --- a/database/migrations/2026_09_16_000100_backfill_refunded_units.php +++ b/database/migrations/2026_09_16_000100_backfill_refunded_units.php @@ -1,147 +1,16 @@ where('refunded_units', '>', 0)->exists()) { - throw new RuntimeException('El backfill requiere que refunded_units sea cero en todos los inventarios.'); - } - - $counts = []; - $variants = []; - $refundsSeen = 0; - $bundlesSkipped = 0; - - DB::table('ticket_refunds as refunds') - ->join('tickets', 'tickets.id', '=', 'refunds.ticket_id') - ->join('compra_items as purchase_items', 'purchase_items.id', '=', 'refunds.purchase_item_id') - ->leftJoin('catalog_items as purchase_catalog', 'purchase_catalog.id', '=', 'purchase_items.source_catalog_item_id') - ->leftJoin('catalog_items as ticket_catalog', 'ticket_catalog.id', '=', 'tickets.source_catalog_item_id') - ->select([ - 'refunds.id', - 'refunds.ticket_id', - 'tickets.source_variant_id', - 'ticket_catalog.inventory_id', - 'ticket_catalog.inventory_policy', - 'ticket_catalog.type as ticket_catalog_type', - 'purchase_catalog.type as purchase_catalog_type', - ]) - ->chunkById(500, function ($refunds) use (&$counts, &$variants, &$refundsSeen, &$bundlesSkipped): void { - foreach ($refunds as $refund) { - $refundsSeen++; - // Bundle purchases need a component allocation that is outside this backfill. - if ($refund->ticket_catalog_type === 'bundle' || $refund->purchase_catalog_type === 'bundle') { - $bundlesSkipped++; - - continue; - } - - if ($refund->inventory_policy === null) { - throw new RuntimeException("El reembolso {$refund->id} no tiene un producto de catálogo asociado."); - } - - $inventoryId = $refund->source_variant_id === null - ? $refund->inventory_id - : $this->currentVariantInventoryId((int) $refund->source_variant_id, $variants); - - if ($inventoryId === null) { - throw new RuntimeException("El reembolso {$refund->id} no tiene un inventario asociado."); - } - - $counts[$inventoryId]['refunded'] = ($counts[$inventoryId]['refunded'] ?? 0) + 1; - if ($refund->inventory_policy === 'tracked') { - $counts[$inventoryId]['stock'] = ($counts[$inventoryId]['stock'] ?? 0) + 1; - } - } - }, 'refunds.id', 'id'); - - foreach ($counts as $inventoryId => $count) { - $updates = ['refunded_units' => DB::raw('refunded_units + '.$count['refunded'])]; - if (($count['stock'] ?? 0) > 0) { - $updates['real_stock'] = DB::raw('real_stock + '.$count['stock']); - } - - if (DB::table('inventories')->where('id', $inventoryId)->update($updates) !== 1) { - throw new RuntimeException("No se encontró el inventario {$inventoryId} para reponerlo."); - } - } - - $refundedUnitsAdded = array_sum(array_column($counts, 'refunded')); - $realStockAdded = array_sum(array_column($counts, 'stock')); - - return [ - 'refunds_seen' => $refundsSeen, - 'refunds_applied' => $refundedUnitsAdded, - 'bundles_skipped' => $bundlesSkipped, - 'inventories_updated' => count($counts), - 'refunded_units_added' => $refundedUnitsAdded, - 'real_stock_added' => $realStockAdded, - ]; - }); - - Log::info('inventory.refunded_units_backfill.completed', $summary); - - // The migrator prints DONE after up() returns. Emit the summary once the - // whole migration run ends so it appears below that line in the console. - if (app()->runningInConsole()) { - $printed = false; - Event::listen(MigrationsEnded::class, static function (MigrationsEnded $event) use ($summary, &$printed): void { - if ($printed || $event->method !== 'up') { - return; - } - $printed = true; - - (new ConsoleOutput)->writeln(sprintf( - ' Refund backfill: %d applied, %d bundles skipped, %d inventories updated, +%d refunded_units, +%d real_stock', - $summary['refunds_applied'], - $summary['bundles_skipped'], - $summary['inventories_updated'], - $summary['refunded_units_added'], - $summary['real_stock_added'], - )); - }); - } - } - - /** @param array $variants */ - private function currentVariantInventoryId(int $variantId, array &$variants): ?int - { - $visited = []; - - while (true) { - if (isset($visited[$variantId])) { - throw new RuntimeException("La cadena de reemplazos de la variante {$variantId} es circular."); - } - $visited[$variantId] = true; - - if (! array_key_exists($variantId, $variants)) { - $variants[$variantId] = DB::table('variantes') - ->where('id', $variantId) - ->first(['inventory_id', 'replaced_by_variant_id']); - } - $variant = $variants[$variantId]; - if ($variant === null) { - throw new RuntimeException("No se encontró la variante {$variantId} de un ticket reembolsado."); - } - if ($variant->replaced_by_variant_id === null) { - return $variant->inventory_id === null ? null : (int) $variant->inventory_id; - } - - $variantId = (int) $variant->replaced_by_variant_id; - } + // Historical reconciliation runs explicitly via tickets:backfill-refunded-units. } public function down(): void { - throw new RuntimeException('El backfill de reembolsos históricos no se puede revertir sin reconciliar el stock.'); + // Reconciliation cannot be reversed without auditing stock. } }; diff --git a/routes/console.php b/routes/console.php index 1d98696..3df5861 100644 --- a/routes/console.php +++ b/routes/console.php @@ -3,6 +3,7 @@ use App\Domains\Auth\Services\AdminCredentialVerifier; use App\Domains\Catalog\Services\ExpireStockReservationsService; use App\Domains\Purchase\Services\TenantTransactionResetService; +use App\Domains\Ticket\Services\BackfillRefundedUnitsService; use App\Domains\Ticket\Services\LoadTestTicketDatasetService; use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; @@ -13,6 +14,27 @@ Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); +Artisan::command('tickets:backfill-refunded-units', function (BackfillRefundedUnitsService $service): int { + try { + $summary = $service->run(); + } catch (Throwable $exception) { + $this->error($exception->getMessage()); + + return self::FAILURE; + } + + $this->info(sprintf( + 'Refund backfill: %d applied, %d bundles skipped, %d inventories updated, +%d refunded_units, +%d real_stock', + $summary['refunds_applied'], + $summary['bundles_skipped'], + $summary['inventories_updated'], + $summary['refunded_units_added'], + $summary['real_stock_added'], + )); + + return self::SUCCESS; +})->purpose('Reconcile historical ticket refunds with inventory after reviewing existing refunded units'); + Artisan::command('reservations:expire', function (): void { $expired = app(ExpireStockReservationsService::class)->expireOverdue(); diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php index 2933595..cba569f 100644 --- a/tests/Feature/Ticket/AdminAppTicketControllerTest.php +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -289,8 +289,7 @@ class AdminAppTicketControllerTest extends TestCase 'refunded_units_added' => 1, 'real_stock_added' => 1, ]); - $backfill = require database_path('migrations/2026_09_16_000100_backfill_refunded_units.php'); - $backfill->up(); + $this->artisan('tickets:backfill-refunded-units')->assertSuccessful(); $this->assertSame(1, $inventory->fresh()->refunded_units); $this->assertSame(1, $inventory->fresh()->real_stock); @@ -308,6 +307,19 @@ class AdminAppTicketControllerTest extends TestCase $this->assertSame(2, $inventory->fresh()->real_stock); } + public function test_backfill_command_fails_without_changing_existing_refunded_units(): void + { + $inventory = Inventory::query()->create([ + 'real_stock' => 3, + 'refunded_units' => 1, + ]); + + $this->artisan('tickets:backfill-refunded-units')->assertFailed(); + + $this->assertSame(1, $inventory->fresh()->refunded_units); + $this->assertSame(3, $inventory->fresh()->real_stock); + } + public function test_it_records_different_refund_types_for_tickets_from_the_same_purchase_item(): void { $tenant = $this->createTenant('ticket-mixed-refunds');