diff --git a/app/Domains/Auth/Services/AdminCredentialVerifier.php b/app/Domains/Auth/Services/AdminCredentialVerifier.php new file mode 100644 index 0000000..0d5a2f8 --- /dev/null +++ b/app/Domains/Auth/Services/AdminCredentialVerifier.php @@ -0,0 +1,22 @@ +where('email', mb_strtolower(trim($email))) + ->where('rol_codigo', RoleCode::Admin->value) + ->first(); + + return $admin !== null + && ! $admin->locked_until?->isFuture() + && Hash::check($password, $admin->getAuthPassword()); + } +} diff --git a/app/Domains/Purchase/Services/TenantTransactionResetService.php b/app/Domains/Purchase/Services/TenantTransactionResetService.php new file mode 100644 index 0000000..5b5880c --- /dev/null +++ b/app/Domains/Purchase/Services/TenantTransactionResetService.php @@ -0,0 +1,145 @@ + */ + public function preview(string $tenantCode): array + { + $this->ensureTenantExists($tenantCode); + $scope = $this->scope($tenantCode); + + return [ + 'users_preserved' => DB::table('users')->where('tenant_codigo', $tenantCode)->count(), + 'purchases' => $scope['purchase_ids']->count(), + 'purchase_items' => DB::table('compra_items')->whereIn('compra_id', $scope['purchase_ids'])->count(), + 'telepagos_payments' => DB::table('telepagos_payments')->whereIn('compra_id', $scope['purchase_ids'])->count(), + 'telepagos_qr' => DB::table('telepagos_qr')->whereIn('compra_id', $scope['purchase_ids'])->count(), + 'carts' => $scope['cart_ids']->count(), + 'cart_items' => $scope['cart_item_ids']->count(), + 'tickets' => DB::table('tickets')->where('tenant_code', $tenantCode)->count(), + 'stock_reservations' => $this->reservationQuery($scope)->count(), + 'purchase_changes' => DB::table('value_changes') + ->where('tenant_code', $tenantCode) + ->where('trackable_type', Purchase::class) + ->count(), + 'inventories' => $scope['inventory_ids']->count(), + ]; + } + + /** @return array */ + public function reset(string $tenantCode): array + { + $this->ensureTenantExists($tenantCode); + + return DB::transaction(function () use ($tenantCode): array { + $scope = $this->scope($tenantCode); + $purchaseItems = DB::table('compra_items')->whereIn('compra_id', $scope['purchase_ids'])->count(); + $cartItems = $scope['cart_item_ids']->count(); + $telepagosPayments = DB::table('telepagos_payments')->whereIn('compra_id', $scope['purchase_ids'])->count(); + $telepagosQr = DB::table('telepagos_qr')->whereIn('compra_id', $scope['purchase_ids'])->count(); + $summary = [ + 'stock_reservations_deleted' => $this->reservationQuery($scope)->delete(), + 'tickets_deleted' => DB::table('tickets')->where('tenant_code', $tenantCode)->delete(), + 'purchase_changes_deleted' => DB::table('value_changes') + ->where('tenant_code', $tenantCode) + ->where('trackable_type', Purchase::class) + ->delete(), + 'purchases_deleted' => DB::table('compras')->whereIn('id', $scope['purchase_ids'])->delete(), + 'purchase_items_deleted' => $purchaseItems, + 'telepagos_payments_deleted' => $telepagosPayments, + 'telepagos_qr_deleted' => $telepagosQr, + 'carts_deleted' => DB::table('carritos')->whereIn('id', $scope['cart_ids'])->delete(), + 'cart_items_deleted' => $cartItems, + 'inventories_reset' => $scope['inventory_ids']->count(), + 'users_preserved' => DB::table('users')->where('tenant_codigo', $tenantCode)->count(), + ]; + + DB::table('inventories') + ->whereIn('id', $scope['inventory_ids']) + ->update([ + 'real_stock' => DB::raw('real_stock + sold_units'), + 'reserved_stock' => 0, + 'sold_units' => 0, + ]); + + return $summary; + }); + } + + private function ensureTenantExists(string $tenantCode): void + { + if (! DB::table('tenants')->where('codigo', $tenantCode)->exists()) { + throw new InvalidArgumentException("El tenant {$tenantCode} no existe."); + } + } + + /** + * @return array{ + * purchase_ids: Collection, + * cart_ids: Collection, + * cart_item_ids: Collection, + * inventory_ids: Collection + * } + */ + private function scope(string $tenantCode): array + { + $catalogItemIds = DB::table('catalog_items') + ->where('tenant_code', $tenantCode) + ->pluck('id'); + $purchaseIds = DB::table('compras') + ->where('tenant_codigo', $tenantCode) + ->pluck('id'); + $cartIds = DB::table('carritos') + ->where('tenant_codigo', $tenantCode) + ->pluck('id'); + $cartItemIds = DB::table('carrito_items') + ->whereIn('cart_id', $cartIds) + ->pluck('id'); + $inventoryIds = DB::table('variantes') + ->whereIn('catalog_item_id', $catalogItemIds) + ->whereNotNull('inventory_id') + ->pluck('inventory_id') + ->merge( + DB::table('catalog_items') + ->whereIn('id', $catalogItemIds) + ->whereNotNull('inventory_id') + ->pluck('inventory_id'), + ) + ->map(fn ($id): int => (int) $id) + ->unique() + ->values(); + + return [ + 'purchase_ids' => $purchaseIds->map(fn ($id): int => (int) $id), + 'cart_ids' => $cartIds->map(fn ($id): int => (int) $id), + 'cart_item_ids' => $cartItemIds->map(fn ($id): int => (int) $id), + 'inventory_ids' => $inventoryIds, + ]; + } + + /** + * @param array{ + * purchase_ids: Collection, + * cart_ids: Collection, + * cart_item_ids: Collection, + * inventory_ids: Collection + * } $scope + */ + private function reservationQuery(array $scope): Builder + { + return DB::table('stock_reservations') + ->where(function (Builder $query) use ($scope): void { + $query->whereIn('inventory_id', $scope['inventory_ids']) + ->orWhereIn('purchase_id', $scope['purchase_ids']) + ->orWhereIn('cart_item_id', $scope['cart_item_ids']); + }); + } +} diff --git a/routes/console.php b/routes/console.php index ffb9927..fd48c29 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,6 +1,8 @@ everyMinute() ->withoutOverlapping(); + +Artisan::command( + 'tenants:reset-transactions + {tenant : Código del tenant que se limpiará} + {--dry-run : Mostrar el alcance sin modificar datos} + {--force : Omitir la confirmación interactiva}', + function ( + TenantTransactionResetService $resetService, + AdminCredentialVerifier $adminCredentialVerifier, + ): int { + $tenantCode = (string) $this->argument('tenant'); + + try { + $preview = $resetService->preview($tenantCode); + } catch (InvalidArgumentException $exception) { + $this->error($exception->getMessage()); + + return self::FAILURE; + } + + $this->warn("Se eliminarán los datos transaccionales de: {$tenantCode}"); + $this->table( + ['Dato', 'Cantidad'], + collect($preview)->map(fn (int $count, string $label): array => [$label, $count])->values(), + ); + $this->info('Los usuarios, el catálogo, los eventos y la configuración se conservarán.'); + + if ($this->option('dry-run')) { + $this->comment('Vista previa finalizada; no se modificaron datos.'); + + return self::SUCCESS; + } + + if (! $this->option('force') && ! $this->confirm('¿Confirmás esta limpieza irreversible?')) { + $this->comment('Operación cancelada.'); + + return self::SUCCESS; + } + + $this->newLine(); + $this->warn('Autorización administrativa requerida.'); + $adminEmail = (string) $this->ask('Email del administrador'); + $adminPassword = (string) $this->secret('Contraseña del administrador'); + + if (! $adminCredentialVerifier->verify($adminEmail, $adminPassword)) { + $this->error('Las credenciales no son válidas o el usuario no posee el rol admin.'); + + return self::FAILURE; + } + + $summary = $resetService->reset($tenantCode); + + $this->info('Limpieza completada correctamente.'); + $this->table( + ['Resultado', 'Cantidad'], + collect($summary)->map(fn (int $count, string $label): array => [$label, $count])->values(), + ); + + return self::SUCCESS; + }, +)->purpose('Delete tenant sales, carts, tickets and reservations while preserving users and catalog');