diff --git a/db/seeds.rb b/db/seeds.rb index 0628d761..ab806e10 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1267,6 +1267,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