diff --git a/app/Domains/Ticket/Services/AdminAppTicketService.php b/app/Domains/Ticket/Services/AdminAppTicketService.php index 275f614..1c626c9 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketService.php @@ -226,13 +226,13 @@ class AdminAppTicketService 'amount' => number_format($refundAmount, 2, '.', ''), ]); - $this->restoreInventory($ticket); + $this->restoreInventory($ticket, $purchaseItem); return $ticket->refresh()->load(self::RELATIONS); }); } - private function restoreInventory(Ticket $ticket): void + private function restoreInventory(Ticket $ticket, PurchaseItem $purchaseItem): void { $catalogItem = $ticket->sourceCatalogItem; if ($catalogItem === null) { @@ -242,7 +242,7 @@ class AdminAppTicketService } // Bundle components need a per-ticket allocation before they can be restored. - if ($catalogItem->isBundle()) { + if ($catalogItem->isBundle() || $purchaseItem->sourceCatalogItem?->isBundle()) { return; } diff --git a/database/migrations/2026_09_16_000000_add_refunded_units_to_inventories.php b/database/migrations/2026_09_16_000000_add_refunded_units_to_inventories.php index 0727aab..a512d88 100644 --- a/database/migrations/2026_09_16_000000_add_refunded_units_to_inventories.php +++ b/database/migrations/2026_09_16_000000_add_refunded_units_to_inventories.php @@ -11,7 +11,6 @@ return new class extends Migration Schema::table('inventories', function (Blueprint $table): void { $table->unsignedBigInteger('refunded_units')->default(0); }); - } public function down(): void diff --git a/database/migrations/2026_09_16_000100_backfill_refunded_units.php b/database/migrations/2026_09_16_000100_backfill_refunded_units.php new file mode 100644 index 0000000..5996107 --- /dev/null +++ b/database/migrations/2026_09_16_000100_backfill_refunded_units.php @@ -0,0 +1,147 @@ +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; + } + } + + public function down(): void + { + throw new RuntimeException('El backfill de reembolsos históricos no se puede revertir sin reconciliar el stock.'); + } +}; diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php index f975205..2933595 100644 --- a/tests/Feature/Ticket/AdminAppTicketControllerTest.php +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -23,6 +23,7 @@ use Database\Seeders\AttributeSeeder; use Database\Seeders\AuthorizationSeeder; use Database\Seeders\FiestaFutbolInfantilProductSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Laravel\Sanctum\Sanctum; use Tests\TestCase; @@ -254,7 +255,7 @@ class AdminAppTicketControllerTest extends TestCase $this->assertSame(1, $inventory->fresh()->refunded_units); } - public function test_existing_refunds_do_not_increment_the_new_refunded_units_counter(): void + public function test_backfill_restores_existing_refunds_before_new_refunds(): void { $tenant = $this->createTenant('ticket-historical-refund'); $tenant->update([ @@ -280,6 +281,20 @@ class AdminAppTicketControllerTest extends TestCase $this->assertSame(0, $inventory->fresh()->refunded_units); $this->assertSame(0, $inventory->fresh()->real_stock); + Log::shouldReceive('info')->once()->with('inventory.refunded_units_backfill.completed', [ + 'refunds_seen' => 1, + 'refunds_applied' => 1, + 'bundles_skipped' => 0, + 'inventories_updated' => 1, + 'refunded_units_added' => 1, + 'real_stock_added' => 1, + ]); + $backfill = require database_path('migrations/2026_09_16_000100_backfill_refunded_units.php'); + $backfill->up(); + + $this->assertSame(1, $inventory->fresh()->refunded_units); + $this->assertSame(1, $inventory->fresh()->real_stock); + $newTicket = $this->createTicket($tenant, $admin, [ 'source_purchase_item_id' => $purchaseItem->id, 'source_catalog_item_id' => $historicalTicket->source_catalog_item_id, @@ -289,8 +304,8 @@ class AdminAppTicketControllerTest extends TestCase ])->assertOk(); $this->assertSame(2, $inventory->fresh()->sold_units); - $this->assertSame(1, $inventory->fresh()->refunded_units); - $this->assertSame(1, $inventory->fresh()->real_stock); + $this->assertSame(2, $inventory->fresh()->refunded_units); + $this->assertSame(2, $inventory->fresh()->real_stock); } public function test_it_records_different_refund_types_for_tickets_from_the_same_purchase_item(): void