clientTierService = $clientTierService; } /** * Crear una nueva devolución con sus detalles */ public function createReturn(array $data): Returns { return DB::transaction(function () use ($data) { // 1. Validaciones de negocio $sale = Sale::with(['details.serials', 'cashRegister'])->findOrFail($data['sale_id']); if ($sale->status !== 'completed') { throw new \Exception('Solo se pueden devolver productos de ventas completadas.'); } // Validar que la caja esté abierta si se especificó cash_register_id if (isset($data['cash_register_id'])) { $cashRegister = CashRegister::findOrFail($data['cash_register_id']); if (!$cashRegister->isOpen()) { throw new \Exception('La caja registradora debe estar abierta para procesar devoluciones.'); } } // 2. Calcular totales $subtotal = 0; $tax = 0; foreach ($data['items'] as $item) { $saleDetail = SaleDetail::findOrFail($item['sale_detail_id']); // Validar que no se devuelva más de lo vendido (restando lo ya devuelto) $alreadyReturned = ReturnDetail::where('sale_detail_id', $saleDetail->id) ->sum('quantity_returned'); $maxReturnable = $saleDetail->quantity - $alreadyReturned; if ($item['quantity_returned'] > $maxReturnable) { throw new \Exception( "Solo puedes devolver {$maxReturnable} unidades de {$saleDetail->product_name}. " . "Ya se devolvieron {$alreadyReturned} de {$saleDetail->quantity} vendidas." ); } $itemSubtotal = $saleDetail->unit_price * $item['quantity_returned']; $subtotal += $itemSubtotal; } // Calcular impuesto proporcional if ($sale->subtotal > 0) { $taxRate = $sale->tax / $sale->subtotal; $tax = $subtotal * $taxRate; } $total = $subtotal + $tax; // Calcular descuento devuelto proporcional si la venta tenía descuento $discountRefund = 0; if ($sale->discount_amount > 0 && $sale->total > 0) { $returnPercentage = $total / $sale->total; $discountRefund = round($sale->discount_amount * $returnPercentage, 2); } // 3. Crear registro de devolución $return = Returns::create([ 'sale_id' => $sale->id, 'user_id' => $data['user_id'], 'cash_register_id' => $data['cash_register_id'] ?? $this->getCurrentCashRegister($data['user_id']), 'return_number' => $this->generateReturnNumber(), 'subtotal' => $subtotal, 'tax' => $tax, 'total' => $total, 'discount_refund' => $discountRefund, 'refund_method' => $data['refund_method'] ?? $sale->payment_method, 'reason' => $data['reason'], 'notes' => $data['notes'] ?? null, ]); // 4. Crear detalles y restaurar seriales foreach ($data['items'] as $item) { $saleDetail = SaleDetail::findOrFail($item['sale_detail_id']); $returnDetail = ReturnDetail::create([ 'return_id' => $return->id, 'sale_detail_id' => $saleDetail->id, 'inventory_id' => $saleDetail->inventory_id, 'product_name' => $saleDetail->product_name, 'quantity_returned' => $item['quantity_returned'], 'unit_price' => $saleDetail->unit_price, 'subtotal' => $saleDetail->unit_price * $item['quantity_returned'], ]); $inventory = $saleDetail->inventory; if ($inventory->track_serials) { // Validación de cantidad de seriales if (isset($item['serial_numbers']) && is_array($item['serial_numbers'])) { if (count($item['serial_numbers']) != $item['quantity_returned']) { throw new \Exception( "La cantidad de seriales proporcionados no coincide con la cantidad a devolver " . "para {$saleDetail->product_name}." ); } } // Gestionar seriales if (isset($item['serial_numbers']) && is_array($item['serial_numbers'])) { // Seriales específicos proporcionados foreach ($item['serial_numbers'] as $serialNumber) { $serial = InventorySerial::where('serial_number', $serialNumber) ->where('sale_detail_id', $saleDetail->id) ->where('status', 'vendido') ->first(); if (!$serial) { throw new \Exception( "El serial {$serialNumber} no pertenece a esta venta o ya fue devuelto." ); } // Marcar como devuelto y vincular a devolución $serial->markAsReturned($returnDetail->id); // Luego restaurar a disponible $serial->restoreFromReturn(); } } else { // Seleccionar automáticamente los primeros N seriales vendidos $serials = InventorySerial::where('sale_detail_id', $saleDetail->id) ->where('status', 'vendido') ->limit($item['quantity_returned']) ->get(); if ($serials->count() < $item['quantity_returned']) { throw new \Exception( "No hay suficientes seriales disponibles para devolver de {$saleDetail->product_name}" ); } foreach ($serials as $serial) { $serial->markAsReturned($returnDetail->id); $serial->restoreFromReturn(); } } // Sincronizar el stock del inventario $saleDetail->inventory->syncStock(); } else { $inventory->increment('stock', $item['quantity_returned']); } } // 5. Actualizar estadísticas del cliente si existe if ($sale->client_id) { $client = Client::find($sale->client_id); if ($client) { $this->clientTierService->revertClientPurchaseStats($client, $return->total); } } // 6. Actualizar cash register con descuento devuelto y returns if ($return->cash_register_id) { $cashRegister = CashRegister::find($return->cash_register_id); if ($cashRegister) { // Actualizar tracking de devoluciones $cashRegister->increment('total_returns', $return->total); $cashRegister->increment('returns_count', 1); // Actualizar tracking de descuentos devueltos (restar de total_discounts) if ($discountRefund > 0) { $cashRegister->decrement('total_discounts', $discountRefund); } // Actualizar por método de pago if ($return->refund_method === 'cash') { $cashRegister->increment('cash_returns', $return->total); } else { $cashRegister->increment('card_returns', $return->total); } } } // 7. Retornar con relaciones cargadas return $return->load([ 'details.inventory', 'details.serials', 'sale.client.tier', 'user', ]); }); } /** * Cancelar una devolución (caso excepcional, restaurar venta) */ public function cancelReturn(Returns $return): Returns { return DB::transaction(function () use ($return) { // Restaurar seriales a estado vendido foreach ($return->details as $detail) { if ($detail->inventory->track_serials) { $serials = InventorySerial::where('return_detail_id', $detail->id)->get(); foreach ($serials as $serial) { $serial->update([ 'status' => 'vendido', 'sale_detail_id' => $detail->sale_detail_id, 'return_detail_id' => null, ]); } // Sincronizar stock $detail->inventory->syncStock(); } else { // Revertir stock numérico (la devolución lo había incrementado) $detail->inventory->decrement('stock', $detail->quantity_returned); } } // Eliminar la devolución (soft delete) $return->delete(); return $return->fresh(['details.inventory', 'details.serials', 'sale']); }); } /** * Obtener items elegibles para devolución de una venta */ public function getReturnableItems(Sale $sale): array { if ($sale->status !== 'completed') { throw new \Exception('Solo se pueden ver items de ventas completadas.'); } $returnableItems = []; foreach ($sale->details as $detail) { $alreadyReturned = ReturnDetail::where('sale_detail_id', $detail->id) ->sum('quantity_returned'); $maxReturnable = $detail->quantity - $alreadyReturned; if ($maxReturnable > 0) { $availableSerials = []; if ($detail->inventory->track_serials) { // Obtener seriales vendidos que aún no han sido devueltos $availableSerials = InventorySerial::where('sale_detail_id', $detail->id) ->where('status', 'vendido') ->get() ->map(fn($serial) => [ 'serial_number' => $serial->serial_number, 'status' => $serial->status, ]); } $returnableItems[] = [ 'sale_detail_id' => $detail->id, 'inventory_id' => $detail->inventory_id, 'product_name' => $detail->product_name, 'quantity_sold' => $detail->quantity, 'quantity_already_returned' => $alreadyReturned, 'quantity_returnable' => $maxReturnable, 'unit_price' => $detail->unit_price, 'available_serials' => $availableSerials, ]; } } return $returnableItems; } /** * Generar número de devolución único * Formato: RET-YYYYMMDD-0001 */ private function generateReturnNumber(): string { $prefix = 'DEV-'; $date = now()->format('Ymd'); $lastReturn = Returns::whereDate('created_at', today()) ->orderBy('id', 'desc') ->first(); $sequential = $lastReturn ? (intval(substr($lastReturn->return_number, -4)) + 1) : 1; return $prefix . $date . '-' . str_pad($sequential, 4, '0', STR_PAD_LEFT); } /** * Obtener caja registradora activa del usuario */ private function getCurrentCashRegister($userId): ?int { $register = CashRegister::where('user_id', $userId) ->where('status', 'open') ->first(); return $register?->id; } }