Class: Crm::AmazonProductsController

Inherits:
CrmController show all
Defined in:
app/controllers/crm/amazon_products_controller.rb

Constant Summary collapse

AMAZON_SELLER_IDS =

Amazon's seller IDs (US and Canada) - same as in AmazonPricingAutomationService

%w[A2R2RITDJNW1Q6 A3DWYIK6Y9EEQB].freeze

Constants included from Controllers::ReferenceFindable

Controllers::ReferenceFindable::ID_EMBEDDED_PATTERNS

Constants included from Controllers::AnalyticsEvents

Controllers::AnalyticsEvents::MAX_QUEUED_EVENTS, Controllers::AnalyticsEvents::SESSION_KEY

Constants included from Controllers::ErrorRendering

Controllers::ErrorRendering::NON_CONTENT_PATH_PREFIXES

Constants included from Www::SeoHelper

Www::SeoHelper::AWARDS, Www::SeoHelper::CA_ADDRESS, Www::SeoHelper::CA_BUSINESS_HOURS, Www::SeoHelper::CA_CONTACT_POINT, Www::SeoHelper::CA_CURRENCIES, Www::SeoHelper::CA_DESCRIPTION, Www::SeoHelper::CA_FOUNDING_DATE, Www::SeoHelper::CA_GLOBAL_LOCATION_NUMBER, Www::SeoHelper::CA_LEGAL_NAME, Www::SeoHelper::CA_LOCAL_BUSINESS, Www::SeoHelper::CA_ONLINE_STORE, Www::SeoHelper::CA_RETURN_POLICY, Www::SeoHelper::CA_SALES_DEPARTMENT, Www::SeoHelper::CA_SERVICE_AREA, Www::SeoHelper::CA_URL, Www::SeoHelper::CA_VAT_ID, Www::SeoHelper::CA_WAREHOUSE_DEPARTMENT, Www::SeoHelper::CA_WAREHOUSE_HOURS, Www::SeoHelper::COMPANY_EMAIL, Www::SeoHelper::COMPANY_LOGO, Www::SeoHelper::COMPANY_NAME, Www::SeoHelper::COMPANY_SLOGAN, Www::SeoHelper::EXPERTISE, Www::SeoHelper::FAX_NUMBER, Www::SeoHelper::GS1_COMPANY_PREFIX, Www::SeoHelper::ISO6523_CODE, Www::SeoHelper::PAYMENT_METHODS, Www::SeoHelper::PHONE_NUMBER, Www::SeoHelper::PRIMARY_NAICS, Www::SeoHelper::REFUND_TYPE, Www::SeoHelper::RETURN_FEES, Www::SeoHelper::RETURN_METHOD, Www::SeoHelper::RETURN_POLICY_CATEGORY, Www::SeoHelper::SECONDARY_NAICS, Www::SeoHelper::SOCIAL_PROFILES, Www::SeoHelper::US_ADDRESS, Www::SeoHelper::US_BUSINESS_HOURS, Www::SeoHelper::US_CONTACT_POINT, Www::SeoHelper::US_CURRENCIES, Www::SeoHelper::US_DESCRIPTION, Www::SeoHelper::US_FOUNDING_DATE, Www::SeoHelper::US_GLOBAL_LOCATION_NUMBER, Www::SeoHelper::US_IMAGE, Www::SeoHelper::US_LEGAL_NAME, Www::SeoHelper::US_LOCAL_BUSINESS, Www::SeoHelper::US_ONLINE_STORE, Www::SeoHelper::US_RETURN_POLICY, Www::SeoHelper::US_SALES_DEPARTMENT, Www::SeoHelper::US_SERVICE_AREA, Www::SeoHelper::US_TAX_ID, Www::SeoHelper::US_URL, Www::SeoHelper::US_WAREHOUSE_DEPARTMENT, Www::SeoHelper::US_WAREHOUSE_HOURS

Constants included from IconHelper

IconHelper::CUSTOM_ICON_MAP, IconHelper::CUSTOM_SVG_DIR, IconHelper::DEFAULT_FAMILY

Instance Method Summary collapse

Methods inherited from CrmController

#access_denied, #context_id, #context_object, #crm_home_path, #current_ability, #default_url_options, #download_temp, #get_tempfile_path_for_download, #initialize_crm_lazy_chunks, #record_not_found, #redirect_to_job_or_fallback, #render_edit_action, #set_context, #set_download_path, #stash_file_for_temp_download

Methods inherited from ApplicationController

#account_impersonated?, #add_to_flash, #append_token, #bypass_forgery_protection?, #chat_enabled?, #cloudflare_cleared?, #default_catalog, #default_url_options, #enable_turbo_frames, #find_publication, #fix_invalid_accept_header, #init_js_utils, #is_globals_call?, #layout_by_resource, #locale_store, #redirect_to, #require_employee_for_crm, #set_base_host, #set_real_ip, #set_report_errors_for, #should_render_layout?, #stamp_impersonation_context, #warmlyyours_canada_ip?, #warmlyyours_ip?, #y

Methods included from Controllers::ReturnPathHandling

#check_for_return_path, #redirect_to_return_path_or_default

Methods included from Controllers::AnalyticsEvents

#consume_queued_analytics_events, #track_event

Methods included from Controllers::DeviceDetection

#device_detector, #is_ie?

Methods included from Controllers::SubdomainDetection

#is_crm_request?, #is_www_request?, #json_request?

Methods included from Controllers::TrackingDetection

#bot_request?, #gdpr_country?, #gdpr_country_data, #prevent_bots, #set_tracking_cookie, #track_visitor?

Methods included from Controllers::AcceleratedFileSending

#send_file_accelerated, #send_upload_accelerated

Methods included from Controllers::ErrorRendering

#excp_string, #mail_to_for_error_reporting, #render_400, #render_404, #render_406, #render_410, #render_500, #render_invalid_authenticity_token, #render_ip_spoof_error, #safe_referer_or_fallback

Methods included from Controllers::TurnstileVerification

#load_turnstile_script_tag, #turnstile_lazy_widget, #turnstile_script_tag, #turnstile_widget, #validate_turnstile!

Methods included from Controllers::CloudflareCaching

edge_cached, #edge_cached_action?, #reset_cloudflare_cache, #set_cloudflare_cache, #skip_session

Methods included from Controllers::Webpackable

#preload_webpack_fonts, #webpack_css_include, #webpack_css_url, #webpack_js_include, #wpd_is_running?

Methods included from Controllers::Localizable

#cloudflare_country_locale, #determine_request_locale, #geocoder_locale, #guest_user_locale_check, #locale_optional_www_auth_path?, #param_locale, #set_locale, #set_request_locale, #skip_localization?, #warmlyyours_ip_locale

Methods included from Controllers::Authenticable

#access_denied, #authenticate_account, #authenticate_account!, #authenticate_account_from_login_token!, #authenticate_account_from_token!, #check_is_a_manager, #check_is_a_sales_manager, #check_is_an_admin, #check_is_an_employee, #check_party, #clear_mismatched_guest_user, #create_guest_user, #credentials?, #current_or_guest_user, #current_or_guest_user_id_read_only, #current_user, #devise_mapping, #fully_logged_in?, #generate_bot_id, #guest_user, #identifiable?, #init_current_user, #initialize_guest, #load_context_user, #logging_in, #resource, #resource_name, #restrict_access_for_non_employees, #scrubbed_request_path, #user_object, #warn_on_session_guest_id_leak

Methods included from ApplicationHelper

#better_number_to_currency, #check_force_logout, #check_or_cross, #check_or_times, #error_messages, #general_disclaimer_on_product_installation_and_local_codes, #gridjs_from_html_table, #gridjs_table, #is_wy_ip, #line_break, #parent_layout, #pass_or_fail, #render_error_messages_list, #render_video_card, #resolved_auth_form_turbo_frame, #return_path_or, #safe_css_color, #set_return_path_if_present, #set_section_if_present, #tab_frame_id, #to_underscore, #track_page?, #turbo_section_wrapper, #turbo_tabs_request?, #url_on_same_domain_as_request, #widget_index_daily_focus_index_path, #working_hours?, #yes_or_no, #yes_or_no_highlighted, #yes_or_no_with_check_or_cross, #youtube_video

Methods included from UppyUploaderHelper

#file_uploader, #image_uploader, #large_file_uploader_s3, #lead_sketch_uploader, #rma_image_uploader, #rma_image_uploader_s3, #uppy_uploader, #video_uploader

Methods included from Www::ImagesHelper

#image_asset_tag, #image_asset_url

Methods included from Www::SeoHelper

#add_page_schema, #canada?, #company_social_links, #ensure_context_json, #json_ld_script_tag, #local_business_schema, #online_store_id, #online_store_schema, #page_main_entity, #page_main_entity_json, #render_auto_collection_page_schema, #render_collection_page_schema, #render_local_business_schema, #render_online_store_schema, #render_page_schemas, #render_page_video_schemas, #render_webpage_schema, #render_webpage_schema_with_collections, #usa?

Methods included from UrlsHelper

#catalog_breadcrumb_links, #catalog_link, #catalog_link_for_product_line, #catalog_link_for_sku, #cms_link, #delocalized_path, #path_to_sales_product_sku, #path_to_sales_product_sku_for_product_line, #path_to_sales_product_sku_for_product_line_slug, #product_line_from_catalog_link, #protocol_neutral_url, #sanitize_external_url, #valid_external_url?

Methods included from IconHelper

#account_nav_icon, #fa_icon, #star_rating_html

Instance Method Details

#add_competitorObject

Add or update a competitor in the database



361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
# File 'app/controllers/crm/amazon_products_controller.rb', line 361

def add_competitor
  authorize!(:manage, AmazonCompetitor)

  # Find existing competitor (from auto-discovery) or create new one
  @competitor = AmazonCompetitor.find_or_initialize_by(seller_id: competitor_params[:seller_id])
  @competitor.assign_attributes(competitor_params)

  if @competitor.save
    action = @competitor.previously_new_record? ? 'Added' : 'Updated'
    flash[:success] = "#{action} competitor: #{@competitor.name} (#{@competitor.seller_id})"
  else
    flash[:error] = "Failed to save competitor: #{@competitor.errors.full_messages.join(', ')}"
  end

  redirect_to competitors_amazon_catalog_items_path
end

#competitorsObject

Show unknown competitors that have won Buy Box on our products



164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
# File 'app/controllers/crm/amazon_products_controller.rb', line 164

def competitors
  # Scan ALL Amazon Seller Central catalogs (US, Canada, etc.)
  @catalog_ids = CatalogConstants::AMAZON_SELLER_IDS
  @catalogs = Catalog.where(id: @catalog_ids)

  # Get merchant IDs for all catalogs
  @merchant_ids = @catalogs.filter_map { |c| c.load_orchestrator&.merchant_id }.uniq

  # OPTIMIZATION: Fetch ALL competitor data in a single query (cached for 10 minutes)
  all_competitor_data = Rails.cache.fetch('amazon_competitors_data', expires_in: 10.minutes) do
    fetch_all_competitor_data_optimized
  end

  # Get all competitors from database
  all_db_competitors = AmazonCompetitor.all.index_by(&:seller_id)
  named_seller_ids = AmazonCompetitor.named.pluck(:seller_id)

  # Build unified list of ALL competitors
  all_competitor_ids = all_competitor_data.keys
  db_seller_ids = all_db_competitors.keys

  # Combine all seller IDs (from EDI data + database)
  all_seller_ids = (all_competitor_ids + db_seller_ids).uniq

  @all_competitors = all_seller_ids.map do |seller_id|
    db_competitor = all_db_competitors[seller_id]
    edi_data = all_competitor_data[seller_id] || { count: 0, samples: [] }
    catalog_names = edi_data[:samples].filter_map { |s| s[:catalog_name] }.uniq

    # Determine status: known (named), discovered (in DB but unnamed), unknown (not in DB)
    status = if named_seller_ids.include?(seller_id)
               :known
             elsif db_seller_ids.include?(seller_id)
               :discovered
             else
               :unknown
             end

    {
      seller_id: seller_id,
      competitor: db_competitor,
      name: db_competitor&.name || seller_id,
      display_name: db_competitor&.name || "Unknown (#{seller_id[0..6]}...)",
      status: status,
      product_count: edi_data[:count],
      sample_products: edi_data[:samples],
      catalog_names: catalog_names,
      storefront_url: db_competitor&.storefront_url || "https://www.amazon.com/sp?seller=#{seller_id}",
      created_at: db_competitor&.created_at || Time.current,
      notes: db_competitor&.notes
    }
  end

  # Store totals before filtering
  @total_count = @all_competitors.count
  @known_count = @all_competitors.count { |c| c[:status] == :known }
  @unknown_count = @all_competitors.count { |c| c[:status] != :known }

  # Apply status filter (known vs unknown/discovered)
  @filter_status = params[:status]
  if @filter_status == 'known'
    @all_competitors = @all_competitors.select { |c| c[:status] == :known }
  elsif @filter_status == 'unknown'
    @all_competitors = @all_competitors.reject { |c| c[:status] == :known }
  end

  # Apply catalog filter
  @filter_catalog = params[:catalog]
  if @filter_catalog.present? && @filter_catalog != 'all'
    catalog_name_filter = @filter_catalog == 'usa' ? 'USA' : 'Canada'
    @all_competitors = @all_competitors.select do |c|
      c[:catalog_names].any? { |name| name.include?(catalog_name_filter) }
    end
  end

  # Apply competition status filter
  @filter_competing = params[:competing]
  if @filter_competing == 'active'
    @all_competitors = @all_competitors.select { |c| c[:product_count].positive? }
  elsif @filter_competing == 'inactive'
    @all_competitors = @all_competitors.select { |c| c[:product_count].zero? }
  end

  # Apply sorting
  @sort = params[:sort] || 'products_desc'
  @all_competitors = case @sort
                     when 'name_asc'
                       @all_competitors.sort_by { |c| c[:name].to_s.downcase }
                     when 'name_desc'
                       @all_competitors.sort_by { |c| c[:name].to_s.downcase }.reverse
                     when 'products_asc'
                       @all_competitors.sort_by { |c| c[:product_count] }
                     when 'products_desc'
                       @all_competitors.sort_by { |c| -c[:product_count] }
                     when 'recent'
                       @all_competitors.sort_by { |c| c[:created_at] }.reverse
                     when 'status'
                       # Unknown first, then discovered, then known
                       @all_competitors.sort_by { |c| [{ unknown: 0, discovered: 1, known: 2 }[c[:status]], -c[:product_count]] }
                     else
                       @all_competitors.sort_by { |c| -c[:product_count] }
                     end

  @filtered_count = @all_competitors.count
end

#delete_competitorObject

Delete a competitor



394
395
396
397
398
399
400
401
402
403
404
405
406
407
# File 'app/controllers/crm/amazon_products_controller.rb', line 394

def delete_competitor
  authorize!(:manage, AmazonCompetitor)

  @competitor = AmazonCompetitor.find(params[:competitor_id])
  name = @competitor.display_name

  if @competitor.destroy
    flash[:success] = "Deleted competitor: #{name}"
  else
    flash[:error] = "Failed to delete competitor: #{@competitor.errors.full_messages.join(', ')}"
  end

  redirect_to competitors_amazon_catalog_items_path
end

#fetch_all_competitor_data_optimizedObject

Fetch all competitor data in a SINGLE optimized query
Returns: { seller_id => { count: N, samples: [...] }, ... }



272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
# File 'app/controllers/crm/amazon_products_controller.rb', line 272

def fetch_all_competitor_data_optimized
  catalog_ids_sql = CatalogConstants::AMAZON_SELLER_IDS.join(',')
  merchant_ids = Catalog.where(id: CatalogConstants::AMAZON_SELLER_IDS)
                        .filter_map { |c| c.load_orchestrator&.merchant_id }
  merchant_ids_sql = merchant_ids.map { |m| ActiveRecord::Base.connection.quote(m) }.join(',')

  return {} if merchant_ids.empty?

  # Single query to get all competitor offers with catalog item info
  sql = <<~SQL.squish
    WITH latest_logs AS (
      SELECT DISTINCT ON (ed.catalog_item_id)
             ed.catalog_item_id,
             ecl.data,
             ci.catalog_id
      FROM edi_documents ed
      JOIN edi_communication_logs ecl ON ecl.id = ed.edi_communication_log_id
      JOIN catalog_items ci ON ci.id = ed.catalog_item_id
      WHERE ci.catalog_id IN (#{catalog_ids_sql})
        AND ecl.category = 'buy_box_status'
        AND ecl.state = 'processed'
        AND ecl.data IS NOT NULL
      ORDER BY ed.catalog_item_id, ecl.created_at DESC
    ),
    competitor_offers AS (
      SELECT
        ll.catalog_item_id,
        ll.catalog_id,
        offer->>'SellerId' as seller_id,
        (offer->'ListingPrice'->>'Amount')::numeric as price
      FROM latest_logs ll,
           LATERAL jsonb_array_elements(ll.data::jsonb -> 'payload' -> 'Offers') AS offer
      WHERE offer->>'SellerId' NOT IN (#{merchant_ids_sql})
    )
    SELECT
      co.seller_id,
      COUNT(DISTINCT co.catalog_item_id) as product_count,
      json_agg(
        json_build_object(
          'catalog_item_id', co.catalog_item_id,
          'catalog_id', co.catalog_id,
          'price', co.price
        )
      ) FILTER (WHERE co.catalog_item_id IS NOT NULL) as products
    FROM competitor_offers co
    GROUP BY co.seller_id
    ORDER BY product_count DESC
  SQL

  results = ActiveRecord::Base.connection.execute(sql)

  # Pre-fetch all catalog items we'll need for sample data
  all_catalog_item_ids = results.flat_map do |row|
    products = row['products'] ? JSON.parse(row['products']) : []
    products.take(5).map { |p| p['catalog_item_id'] }
  end.compact.uniq

  catalog_items = CatalogItem.where(id: all_catalog_item_ids)
                             .includes(:catalog, store_item: :item)
                             .index_by(&:id)

  # Build the result hash
  results.each_with_object({}) do |row, hash|
    seller_id = row['seller_id']
    products = row['products'] ? JSON.parse(row['products']) : []

    # Take top 5 products as samples
    samples = products.take(5).filter_map do |p|
      ci = catalog_items[p['catalog_item_id']]
      next unless ci

      {
        sku: ci.sku,
        asin: ci.amazon_asin,
        name: ci.item&.name&.truncate(50),
        competitive_price: p['price'],
        catalog_item_id: ci.id,
        catalog_name: ci.catalog&.name
      }
    end

    hash[seller_id] = {
      count: row['product_count'].to_i,
      samples: samples
    }
  end
end

#full_refreshObject

Full refresh: Pull Amazon data + trigger retailer probes for all sibling catalogs



106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
# File 'app/controllers/crm/amazon_products_controller.rb', line 106

def full_refresh
  authorize!(:update, @catalog_item)

  # 1. Queue Amazon buy box and catalog info pull (uses same worker as other actions for Turbo Stream broadcasts)
  amazon_options = {
    catalog_item_ids: [@catalog_item.id],
    operation: %w[PULL_BUY_BOX_STATUS PULL_LISTING_INFORMATION PULL_CATALOG_INFORMATION]
  }

  job_id = AmazonItemOperationWorker.perform_async(**amazon_options)
  job_id ||= SidekiqUniqueJobsJidLookup.active_jid_for_args(AmazonItemOperationWorker, amazon_options)

  # 2. Queue retailer probes for this item
  RetailerProbeWorker.perform_async(catalog_item_id: @catalog_item.id)

  # 3. Queue retailer probes for all sibling catalog items (same product, different retailers)
  sibling_probe_count = queue_sibling_retailer_probes(@catalog_item)

  # Build status message
  messages = ['Amazon data refresh queued']
  messages << "#{sibling_probe_count + 1} retailer probe(s) queued" if sibling_probe_count >= 0

  render json: {
    status: 'queued',
    job_id: job_id,
    message: messages.join('. ')
  }
end

#indexObject



10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
# File 'app/controllers/crm/amazon_products_controller.rb', line 10

def index
  # Support multiple catalog_ids (array) for multi-select filter
  @catalog_ids = parse_catalog_ids(params[:catalog_id])
  @catalogs_selected = Catalog.where(id: @catalog_ids).order(:name)

  # Cache merchant_ids for all selected catalogs (used in helpers)
  @merchant_ids = @catalogs_selected.filter_map { |c| c.load_orchestrator&.merchant_id }.uniq

  # For backward compatibility with single catalog helpers, use first catalog
  @catalog = @catalogs_selected.first
  @catalog_id = @catalog&.id
  @merchant_id = @merchant_ids.first

  # Use the pre-joined view for efficient querying without DISTINCT + ORDER BY conflicts
  # The view already joins catalog_items, store_items, items, and catalogs
  base_scope = ViewAmazonCatalogItem
               .where(catalog_id: @catalog_ids)
               .where(state: filter_states)

  # Apply custom filters (not ransack)
  base_scope = apply_issues_filter(base_scope)
  base_scope = apply_buy_box_filter(base_scope)
  base_scope = apply_fresh_data_filter(base_scope)

  @q = base_scope.ransack(params[:q])

  # Default sort by SKU if no sort specified
  @q.sorts = 'sku asc' if @q.sorts.empty?

  # Pagination - no distinct needed since view has 1:1 joins
  @pagy, @catalog_items = pagy(@q.result, limit: params[:per_page] || 50)

  # Preload associations not included in view
  ActiveRecord::Associations::Preloader.new(
    records: @catalog_items,
    associations: %i[amazon_catalog_item_flags catalog]
  ).call

  # Preload latest buy_box_status EDI logs for the paginated items to avoid N+1
  preload_edi_logs(@catalog_items)

  # Preload Amazon competitors for all seller IDs we might display
  preload_amazon_competitors(@catalog_items)

  # Preload root catalog items for MSRP lookup
  preload_root_catalog_items(@catalog_items)

  # Preload sibling catalog items with retail prices (from retailer probes)
  preload_sibling_retailer_prices(@catalog_items)

  # Available catalogs for filter dropdown (Seller Central only)
  @catalogs = Catalog.where(id: CatalogConstants::AMAZON_SELLER_IDS).order(:name)

  respond_to do |format|
    format.html do
      # Stats for header (cached for 5 minutes to avoid expensive recalculation)
      cache_key = "amazon_dashboard_stats_#{@catalog_ids.sort.join('_')}"
      @stats = Rails.cache.fetch(cache_key, expires_in: 5.minutes) do
        calculate_stats
      end
    end
    format.csv do
      # For CSV export, get all results (no pagination) up to 10,000 records
      all_items = @q.result.limit(10_000)
      send_data generate_csv(all_items),
                filename: "amazon-catalog-items-#{Date.current}.csv",
                type: 'text/csv; charset=utf-8'
    end
  end
end

#job_statusObject

Check job status (for polling)



136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
# File 'app/controllers/crm/amazon_products_controller.rb', line 136

def job_status
  job = BackgroundJobStatus.find(params[:job_id])

  if job.nil?
    render json: { status: 'not_found', message: 'Job not found' }, status: :not_found
    return
  end

  status = if job.complete? || job.completed?
             'completed'
           elsif job.failed? || job.stopped? || job.interrupted?
             'failed'
           elsif job.working?
             'working'
           else
             'queued'
           end

  render json: {
    status: status,
    message: job.message || job.error_message,
    progress: job.pct_complete,
    catalog_item_id: @catalog_item.id,
    amazon_info_datetime: @catalog_item.reload.amazon_info_datetime&.iso8601
  }
end

#match_competitive_priceObject

Lower price to match competitive price (from Amazon's reported data)
Uses Catalog::UpdateCatalogItem to trigger EDI price message



411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
# File 'app/controllers/crm/amazon_products_controller.rb', line 411

def match_competitive_price
  authorize!(:update, @catalog_item)

  # Get competitive price from Amazon's reported data (NOT from retailer probes)
  competitive_data = get_best_competitor_for_catalog_item(@catalog_item)

  unless competitive_data&.dig(:price)
    flash[:error] = 'No competitive price data available from Amazon'
    redirect_back_or_to(amazon_catalog_items_path)
    return
  end

  competitive_price = competitive_data[:price]
  our_price = @catalog_item.amazon_price_with_tax
  min_price = @catalog_item.amazon_minimum_seller_allowed_price_with_tax

  # Don't lower if we're already at or below competitive price
  if our_price <= competitive_price
    flash[:info] = "Our price ($#{our_price}) is already at or below competitive price ($#{competitive_price})"
    redirect_back_or_to(amazon_catalog_items_path)
    return
  end

  # Determine target price based on competitor type:
  # - External competitors (Amazon threshold from other marketplaces): just match
  # - Seller competitors (other sellers on the listing): beat by $0.01
  is_external = competitive_data[:seller_id] == 'EXTERNAL_COMPETITOR'
  target_price = if is_external
                   competitive_price.round(2) # Match external threshold
                 else
                   (competitive_price - 0.01).round(2) # Beat seller by 1 cent
                 end
  action_verb = is_external ? 'match' : 'beat'

  # Check minimum price
  if target_price < min_price
    flash[:error] = "Target price $#{target_price} is below minimum allowed $#{min_price}"
    redirect_back_or_to(amazon_catalog_items_path)
    return
  end

  # Convert price with tax to amount (without tax) for storage
  new_amount = if @catalog_item.tax_rate.present? && @catalog_item.tax_rate.positive?
                 (target_price / (1 + @catalog_item.tax_rate)).round(2)
               else
                 target_price
               end

  # Use UpdateCatalogItem service to trigger EDI price message via subscriber
  PaperTrail.request(whodunnit: current_user&.name || 'AmazonProductsController') do
    result = Catalog::UpdateCatalogItem.new.process(
      @catalog_item,
      { amount: new_amount },
      force_coupon_sync: false
    )

    if result.catalog_item_updated?
      competitor_name = is_external ? 'external threshold' : "competitor #{competitive_data[:seller_id]}"
      flash[:success] = "Price lowered from $#{our_price} to $#{target_price} to #{action_verb} #{competitor_name}. EDI price update queued."
    else
      flash[:error] = "Failed to update price: #{result.messages.join(', ')}"
    end
  end

  redirect_back_or_to(amazon_catalog_items_path)
end

#pull_buy_boxObject

Pull buy box status for a single catalog item (async)



94
95
96
97
98
99
100
101
102
103
# File 'app/controllers/crm/amazon_products_controller.rb', line 94

def pull_buy_box
  authorize!(:update, CatalogItem)

  options = {
    catalog_item_ids: [@catalog_item.id],
    operation: ['PULL_BUY_BOX_STATUS']
  }

  perform_amazon_operation(options, 'Pulling buy box status...')
end

#pull_catalog_infoObject

Pull catalog information for a single catalog item (async)



82
83
84
85
86
87
88
89
90
91
# File 'app/controllers/crm/amazon_products_controller.rb', line 82

def pull_catalog_info
  authorize!(:update, CatalogItem)

  options = {
    catalog_item_ids: [@catalog_item.id],
    operation: %w[PULL_LISTING_INFORMATION PULL_CATALOG_INFORMATION]
  }

  perform_amazon_operation(options, 'Pulling catalog information...')
end

#update_competitorObject

Update an existing competitor



379
380
381
382
383
384
385
386
387
388
389
390
391
# File 'app/controllers/crm/amazon_products_controller.rb', line 379

def update_competitor
  authorize!(:manage, AmazonCompetitor)

  @competitor = AmazonCompetitor.find(params[:competitor_id])

  if @competitor.update(competitor_params)
    flash[:success] = "Updated competitor: #{@competitor.name}"
  else
    flash[:error] = "Failed to update competitor: #{@competitor.errors.full_messages.join(', ')}"
  end

  redirect_to competitors_amazon_catalog_items_path
end