ACF Relationship + WP Template Parts

But, when an offer has no related_products, ALL products are being
shown.

This is because you’re not doing anything to check if the field has a value before running your query. If this is empty:

$related_products = get_field('related_products');

Then that makes the query argument equivalent to:

'post__in' => [],

And if that argument value is empty, it doesn’t query no posts, it just gets ignored, which means you’re query is the equivalent of:

$args = array( 
    'post_type'              => 'product',
    'fields'                 => 'ids',
    'cache_results'          => false,
    'update_post_meta_cache' => false, 
    'update_post_term_cache' => false, 
    'posts_per_page'         => -1, 
    'paged'                  => false,
);

Which is querying all products.

So you need to check if your field has any values before doing anything, then only querying if there are any.

Your other problem is that you have fields set to ids. If you do this then the result is just an array of posts IDs, which you already have because that’s what you’re querying. If you want to use the loop with your results, you need to remove that line. So all you should have is:

$related_products = get_field( 'related_products' );

if ( $related_products ) {
    $args = array( 
        'post_type' => 'product',
        'post__in'  => $related_products,    
    );

    $custom_query = new WP_Query( $args );

    if ( $custom_query->have_posts() ) :  
        while ( $custom_query->have_posts() ) : 
            $custom_query->the_post(); 
            get_template_part( 'parts/card', get_post_type() );
        endwhile;
    else : 
    // do something else
    endif;

    wp_reset_postdata();
}

But doing this query at all is unnecessary. If you set the field to return post objects, rather than IDs, you can just loop through those:

$related_products = get_field( 'related_products' );

if ( $related_products ) {
    global $post; // Necessary.

    foreach ( $related_products as $post ) : // Must be called $post. 
        setup_postdata( $post ); 
        get_template_part( 'parts/card', get_post_type() );
    endforeach;

    wp_reset_postdata();
}

This is the same as Faye’s answer, I’m just including it for completeness. If you’re receiving any errors from that, then the issue is somewhere else. Probably inside your card template.