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']); $inventory = Inventory::find($saleDetail->inventory_id); // Conversión de equivalencia de unidades en devolución $inputUnitId = $item['unit_of_measure_id'] ?? null; $inputQuantityReturned = (float) $item['quantity_returned']; $usesEquivalence = $inputUnitId && $inputUnitId != $inventory->unit_of_measure_id; $baseQuantityReturned = $usesEquivalence ? $inventory->convertToBaseUnits($inputQuantityReturned, $inputUnitId) : $inputQuantityReturned; // Guardar la cantidad convertida para uso posterior $item['_base_quantity_returned'] = $baseQuantityReturned; $item['_uses_equivalence'] = $usesEquivalence; $item['_input_unit_id'] = $inputUnitId; $item['_input_quantity_returned'] = $inputQuantityReturned; // 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 ($baseQuantityReturned > $maxReturnable) { throw new \Exception( "Solo puedes devolver {$maxReturnable} unidades base de {$saleDetail->product_name}. ". "Ya se devolvieron {$alreadyReturned} de {$saleDetail->quantity} vendidas." ); } $itemSubtotal = $saleDetail->unit_price * $baseQuantityReturned; $subtotal += $itemSubtotal; } unset($item); // 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']); $baseQuantityReturned = $item['_base_quantity_returned']; $usesEquivalence = $item['_uses_equivalence']; $returnDetail = ReturnDetail::create([ 'return_id' => $return->id, 'sale_detail_id' => $saleDetail->id, 'inventory_id' => $saleDetail->inventory_id, 'product_name' => $saleDetail->product_name, 'quantity_returned' => $baseQuantityReturned, 'unit_of_measure_id' => $usesEquivalence ? $item['_input_unit_id'] : null, 'unit_quantity_returned' => $usesEquivalence ? $item['_input_quantity_returned'] : null, 'unit_price' => $saleDetail->unit_price, 'subtotal' => $saleDetail->unit_price * $baseQuantityReturned, ]); $inventory = $saleDetail->inventory; if ($inventory->track_serials) { // Validación de cantidad de seriales if (! empty($item['serial_numbers'])) { if (count($item['serial_numbers']) != $baseQuantityReturned) { throw new \Exception( 'La cantidad de seriales proporcionados no coincide con la cantidad a devolver '. "para {$saleDetail->product_name}." ); } } // Gestionar seriales if (! empty($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($baseQuantityReturned) ->get(); if ($serials->count() < $baseQuantityReturned) { 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 { // Restaurar stock en el almacén $warehouseId = $saleDetail->warehouse_id ?? $this->movementService->getMainWarehouseId(); $this->movementService->updateWarehouseStock($inventory->id, $warehouseId, $baseQuantityReturned); } } // 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 (la devolución lo había incrementado) $warehouseId = $detail->saleDetail->warehouse_id ?? $this->movementService->getMainWarehouseId(); $this->movementService->updateWarehouseStock($detail->inventory_id, $warehouseId, -$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; } }