From fde796dea9e691a1f0e6234b00c98f7bc812216b Mon Sep 17 00:00:00 2001 From: LauAubert Date: Fri, 2 Oct 2026 02:10:37 -0300 Subject: [PATCH] feat: [TESIS-999023] seed four weeks of demo activity for Norte The seeds created every order at the moment they ran, so all of them fell on the same day: the reports curve would draw a single point and the volume per day said nothing. Nothing reached the dead letter queue either, so the failed events screen had nothing to show in the demo. db/seeds/demo_activity.rb adds seventeen manual sales of Distribuidora Norte spread over the last four weeks in Argentina time, most of them dispatched with Andreani or Correo Argentino and a full shipment log, one cancelled and two still pending, plus an exhausted and a pending failed event. It is idempotent like the rest of the seeds, moves no stock, and lives in its own file loaded at the end so it does not collide with the template changes in seeds.rb. Co-Authored-By: Claude Opus 5.5 --- db/seeds.rb | 3 + db/seeds/demo_activity.rb | 139 ++++++++++++++++++++++++++++ spec/db/demo_activity_seeds_spec.rb | 72 ++++++++++++++ 3 files changed, 214 insertions(+) create mode 100644 db/seeds/demo_activity.rb create mode 100644 spec/db/demo_activity_seeds_spec.rb diff --git a/db/seeds.rb b/db/seeds.rb index 29820e21..03919e22 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1050,6 +1050,9 @@ end end +# Cuatro semanas de ventas y dos eventos en la DLQ para la demo (ver el archivo). +load Rails.root.join('db/seeds/demo_activity.rb') + puts "Seeds cargados: #{Company.count} empresas, #{User.count} usuarios, " \ "#{Warehouse.count} depósitos, #{Service.count} servicios, " \ "#{CompanyIntegration.count} integraciones, #{AdminUser.count} admins, " \ diff --git a/db/seeds/demo_activity.rb b/db/seeds/demo_activity.rb new file mode 100644 index 00000000..3044ed65 --- /dev/null +++ b/db/seeds/demo_activity.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +# Actividad histórica de Distribuidora Norte para la demo. +# +# Los seeds de arriba crean las órdenes en el momento de correrlos: todas caen +# el mismo día, y la pantalla de Reportes dibujaba un solo punto. Tampoco había +# nada en la cola de eventos fallidos. Esto agrega cuatro semanas de ventas +# manuales repartidas día por día —algunas despachadas y entregadas, una +# cancelada— y dos eventos en la DLQ, uno agotado y uno pendiente. +# +# Va en un archivo aparte y no dentro de `seeds.rb` para que tocarlo no choque +# con los cambios de las plantillas y las conexiones, que viven allá. +# +# Idempotente como el resto: cada venta se reconoce por el nombre del cliente, +# y cada evento por su error. Las fechas son relativas a cuando se corre, en +# hora de Argentina, para que la ventana de «últimos 30 días» siempre las vea. +# +# No mueve stock: son ventas pasadas, ya despachadas o canceladas, y el stock +# de la demo es el que cargan los seeds de arriba. +module DemoActivity + ZONE = ActiveSupport::TimeZone['America/Argentina/Buenos_Aires'] + + PRICES = { + 'NOR-001' => 149_999.99, 'NOR-002' => 699_999.50, 'NOR-003' => 24_999.00, + 'NOR-004' => 18_500.00, 'NOR-005' => 3_200.00, 'NOR-006' => 95_000.00, + 'NOR-007' => 12_800.00 + }.freeze + + # [días atrás, cliente, líneas [sku, cantidad], estado, envío] + # envío: nil (sin envío), :in_transit o :delivered. + SALES = [ + [27, 'Ferretería San Martín', [['NOR-005', 10], ['NOR-007', 4]], 'paid', :delivered], + [25, 'Estudio Contable Ríos', [['NOR-001', 2]], 'paid', :delivered], + [24, 'Colegio Nacional', [['NOR-002', 3]], 'paid', :delivered], + [22, 'Panadería La Espiga', [['NOR-003', 4]], 'paid', :delivered], + [20, 'Clínica del Parque', [['NOR-006', 2], ['NOR-004', 3]], 'paid', :delivered], + [18, 'Hotel Bristol', [['NOR-001', 1], ['NOR-003', 6]], 'paid', :delivered], + [17, 'Constructora Lomas', [['NOR-007', 12]], 'cancelled', nil], + [15, 'Agencia Sur Viajes', [['NOR-002', 1]], 'paid', :delivered], + [13, 'Municipalidad de Berisso', [['NOR-005', 30], ['NOR-004', 6]], 'paid', :delivered], + [11, 'Óptica Visión', [['NOR-003', 3]], 'paid', :delivered], + [9, 'Librería Fausto', [['NOR-001', 2], ['NOR-005', 5]], 'paid', :delivered], + [8, 'Taller Mecánico Ruta 2', [['NOR-007', 6]], 'paid', :in_transit], + [6, 'Consultorio Dr. Pérez', [['NOR-006', 1]], 'paid', :in_transit], + [5, 'Gimnasio Olimpo', [['NOR-004', 8]], 'paid', :in_transit], + [3, 'Distribuidora Este', [['NOR-002', 2], ['NOR-003', 2]], 'paid', :in_transit], + [2, 'Kiosco El Sol', [['NOR-005', 4]], 'pending', nil], + [1, 'Bar Notable', [['NOR-001', 1]], 'pending', nil] + ].freeze + + module_function + + def run + company = Company.find_by(slug: 'norte') + return if company.nil? + + Current.set(company_id: company.id) do + context = context_for(company) + next if context.nil? + + SALES.each_with_index { |sale, index| seed_sale(context, index, sale) } + seed_failed_events(company) + end + end + + # Lo que las ventas necesitan de los seeds de arriba. Sin alguna de esas + # piezas (otra base, seeds a medias) no se siembra nada. + COURIERS = ['Andreani', 'Correo Argentino'].freeze + + def context_for(company) + warehouse = Warehouse.find_by(name: 'Depósito Central') + products = Product.where(sku: PRICES.keys).index_by(&:sku) + couriers = CompanyIntegration.joins(:service).where(services: { service_name: COURIERS }).to_a + return if warehouse.nil? || products.size < PRICES.size || couriers.empty? + + { company: company, warehouse: warehouse, products: products, couriers: couriers } + end + + def seed_sale(context, index, sale) + days_ago, customer, lines, status, shipping = sale + return if Order.exists?(customer_name: customer) + + at = ZONE.now.beginning_of_day - days_ago.days + (9 + (index % 9)).hours + order = create_order(context, index, customer, status, at) + lines.each do |sku, quantity| + OrderItem.create!(order: order, product: context[:products].fetch(sku), + warehouse: context[:warehouse], quantity: quantity, + unit_price: PRICES.fetch(sku)) + end + order.update_columns(total_amount: order.items_total) # rubocop:disable Rails/SkipsModelValidations + seed_shipment(context, order, index, at, shipping) if shipping + end + + def create_order(context, index, customer, status, at) + Order.create!(company: context[:company], customer_name: customer, status: status, + customer_address: "Calle #{10 + index} N° #{100 + index}, La Plata", + customer_zip_code: '1900', customer_province: 'Buenos Aires', + created_at: at, updated_at: at) + end + + # La bitácora es lo que fecha el despacho (Reportes cuenta las unidades por el + # evento `ready_to_ship`), así que cada envío lleva su historia completa. + def seed_shipment(context, order, index, at, shipping) + shipment = Shipment.create!( + company: context[:company], order: order, status: shipping.to_s, + company_integration: context[:couriers][index % context[:couriers].size], + tracking_number: format('DEMO-%06d', 100 + index), shipping_cost: 4_500 + (index * 350) + ) + events = [['ready_to_ship', 'Etiqueta generada', 1.day], ['in_transit', 'En camino', 2.days]] + events << ['delivered', 'Entregado', 4.days] if shipping == :delivered + events.each do |internal, external, offset| + ShipmentEvent.create!(shipment: shipment, internal_status: internal, + external_status: external, occurred_at: [at + offset, ZONE.now].min) + end + end + + # Uno agotado y uno pendiente, para que la cola de eventos fallidos tenga qué + # mostrar. El agotado apunta al webhook inválido que cargan los seeds: si se + # lo reintenta, vuelve a fallar por el mismo motivo, como pasaría de verdad. + def seed_failed_events(company) + invalid_log = WebhookLog.where(company_id: company.id, status: 'failed').first + ml = CompanyIntegration.joins(:service).find_by(services: { service_name: 'Mercado Libre' }) + + unless FailedEvent.exists?(last_error: 'the payload does not carry any order item') + FailedEvent.create!(company: company, company_integration: ml, direction: 'inbound', + event_type: 'webhooks.order_ingestion', status: 'dead', attempts: 5, + payload: { 'webhook_log_id' => invalid_log&.id }, + last_error: 'the payload does not carry any order item') + end + return if FailedEvent.exists?(last_response_status: 503) + + FailedEvent.create!(company: company, company_integration: ml, direction: 'outbound', + event_type: 'integrations.http_request', status: 'pending', attempts: 2, + next_retry_at: 4.minutes.from_now, last_response_status: 503, + last_error: 'Service Unavailable', payload: {}) + end +end + +DemoActivity.run diff --git a/spec/db/demo_activity_seeds_spec.rb b/spec/db/demo_activity_seeds_spec.rb new file mode 100644 index 00000000..303fd408 --- /dev/null +++ b/spec/db/demo_activity_seeds_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require 'rails_helper' + +# Los seeds completos, como los corre `bin/rails db:seed`, con la actividad +# histórica de la demo al final (db/seeds/demo_activity.rb). +RSpec.describe 'Demo activity seeds' do # rubocop:disable RSpec/DescribeClass + def seed! + load Rails.root.join('db/seeds.rb') + ensure + Current.reset + end + + def norte = Company.find_by!(slug: 'norte') + + def in_norte(&) = Current.set(company_id: norte.id, &) + + def demo_orders + in_norte { Order.where(customer_name: DemoActivity::SALES.map(&:second)).to_a } + end + + # `seeds.rb` imprime un resumen (se silencia) y deja el adaptador de jobs en + # `:test` (se restaura). + around do |example| + adapter = ActiveJob::Base.queue_adapter + example.run + ensure + ActiveJob::Base.queue_adapter = adapter + end + + before do + allow($stdout).to receive(:puts) + seed! + end + + def dispatch_days + in_norte do + ShipmentEvent.joins(:shipment).where(shipments: { order_id: demo_orders.map(&:id) }, + internal_status: 'ready_to_ship') + .pluck(:occurred_at).map(&:to_date).uniq + end + end + + it 'spreads four weeks of sales over many days' do + days = demo_orders.map { |order| order.created_at.in_time_zone(DemoActivity::ZONE).to_date } + + expect(days.uniq.size).to be >= 15 + end + + it 'leaves nothing to add when the seeds run again' do + expect { seed! }.not_to(change { [Order.unscoped.count, FailedEvent.unscoped.count] }) + end + + # Reportes fecha el despacho por el evento `ready_to_ship` de la bitácora: sin + # despachos repartidos en el tiempo, la curva y el volumen despachado no + # tendrían qué mostrar. + it 'dispatches part of the sales on different days' do + expect(dispatch_days.size).to be > 5 + end + + it 'keeps every demo sale inside the last thirty days' do + oldest = demo_orders.map(&:created_at).min + + expect(oldest).to be > 30.days.ago + end + + it 'leaves an exhausted and a pending event in the dead letter queue' do + statuses = in_norte { FailedEvent.pluck(:status) } + + expect(statuses).to include('dead', 'pending') + end +end